scena 1.5.1

A Rust-native scene-graph renderer with typed scene state, glTF assets, and explicit prepare/render lifecycles.
Documentation
use crate::assets::AssetPath;
use crate::diagnostics::AssetError;

use super::TextureSourceFormat;
use super::texture_ktx2::ktx2_descriptor_only_error;

pub(super) fn resolve_texture_source_bytes(
    path: &AssetPath,
    source_format: TextureSourceFormat,
    source_bytes: Option<&[u8]>,
) -> Result<Option<Vec<u8>>, AssetError> {
    if let Some(bytes) = source_bytes {
        return Ok(Some(bytes.to_vec()));
    }
    if path.as_str().starts_with("data:") {
        return decode_data_uri(path).map(Some);
    }
    match source_format {
        TextureSourceFormat::Ktx2Basisu => Err(ktx2_descriptor_only_error(path)),
        TextureSourceFormat::Png | TextureSourceFormat::Jpeg | TextureSourceFormat::Webp => {
            Ok(None)
        }
    }
}

#[cfg(any(target_arch = "wasm32", test))]
pub(crate) const BROWSER_TEXTURE_MAX_DIMENSION_2D: u32 = 2048;

#[cfg(any(target_arch = "wasm32", test))]
pub(crate) fn browser_texture_resize_dimensions(
    width: u32,
    height: u32,
    max_dimension: u32,
) -> Option<(u32, u32)> {
    if width == 0 || height == 0 || max_dimension == 0 {
        return None;
    }
    let source_max = width.max(height);
    if source_max <= max_dimension {
        return None;
    }
    let source_max = u64::from(source_max);
    let max_dimension = u64::from(max_dimension);
    let resize_axis = |value: u32| -> u32 {
        ((u64::from(value) * max_dimension).div_ceil(source_max)).clamp(1, max_dimension) as u32
    };
    Some((resize_axis(width), resize_axis(height)))
}

#[cfg(target_arch = "wasm32")]
pub(super) fn browser_native_decode_format(source_format: TextureSourceFormat) -> bool {
    matches!(
        source_format,
        TextureSourceFormat::Png | TextureSourceFormat::Jpeg | TextureSourceFormat::Webp
    )
}

#[cfg(target_arch = "wasm32")]
pub(crate) async fn decode_browser_image_bitmap(
    path: &AssetPath,
    bytes: std::sync::Arc<[u8]>,
) -> Result<web_sys::ImageBitmap, AssetError> {
    let window = web_sys::window().ok_or_else(|| AssetError::Io {
        path: path.as_str().to_string(),
        reason: "browser image decode requires a Window".to_string(),
    })?;
    let array = js_sys::Uint8Array::from(bytes.as_ref());
    let parts = js_sys::Array::of1(&array.into());
    let blob = web_sys::Blob::new_with_u8_array_sequence(&parts.into()).map_err(|error| {
        AssetError::Io {
            path: path.as_str().to_string(),
            reason: error
                .as_string()
                .unwrap_or_else(|| format!("Blob construction failed: {error:?}")),
        }
    })?;
    let image = await_browser_image_bitmap(
        path,
        window
            .create_image_bitmap_with_blob(&blob)
            .map_err(|error| browser_image_bitmap_error(path, "createImageBitmap", error))?,
        "createImageBitmap",
    )
    .await?;

    if let Some((width, height)) = browser_texture_resize_dimensions(
        image.width(),
        image.height(),
        BROWSER_TEXTURE_MAX_DIMENSION_2D,
    ) {
        web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
            "scena resized browser texture '{}' from {}x{} to {}x{} to fit the WebGL2-safe {}px texture limit",
            path.as_str(),
            image.width(),
            image.height(),
            width,
            height,
            BROWSER_TEXTURE_MAX_DIMENSION_2D
        )));
        let options = web_sys::ImageBitmapOptions::new();
        options.set_resize_width(width);
        options.set_resize_height(height);
        return await_browser_image_bitmap(
            path,
            window
                .create_image_bitmap_with_blob_and_image_bitmap_options(&blob, &options)
                .map_err(|error| {
                    browser_image_bitmap_error(path, "createImageBitmap resize", error)
                })?,
            "createImageBitmap resize",
        )
        .await;
    }

    Ok(image)
}

#[cfg(target_arch = "wasm32")]
async fn await_browser_image_bitmap(
    path: &AssetPath,
    promise: js_sys::Promise,
    operation: &str,
) -> Result<web_sys::ImageBitmap, AssetError> {
    use wasm_bindgen::JsCast;
    use wasm_bindgen_futures::JsFuture;

    JsFuture::from(promise)
        .await
        .map_err(|error| AssetError::Io {
            path: path.as_str().to_string(),
            reason: error
                .as_string()
                .unwrap_or_else(|| format!("{operation} await failed: {error:?}")),
        })?
        .dyn_into::<web_sys::ImageBitmap>()
        .map_err(|error| AssetError::Io {
            path: path.as_str().to_string(),
            reason: error
                .as_string()
                .unwrap_or_else(|| format!("{operation} returned wrong type: {error:?}")),
        })
}

#[cfg(target_arch = "wasm32")]
fn browser_image_bitmap_error(
    path: &AssetPath,
    operation: &str,
    error: wasm_bindgen::JsValue,
) -> AssetError {
    AssetError::Io {
        path: path.as_str().to_string(),
        reason: error
            .as_string()
            .unwrap_or_else(|| format!("{operation} failed: {error:?}")),
    }
}

fn decode_data_uri(path: &AssetPath) -> Result<Vec<u8>, AssetError> {
    let Some((_, encoded)) = path.as_str().split_once(";base64,") else {
        return Err(AssetError::Parse {
            path: path.as_str().to_string(),
            reason: "only base64 texture data URIs are supported for embedded texture decoding"
                .to_string(),
        });
    };
    use base64::Engine;
    base64::engine::general_purpose::STANDARD
        .decode(encoded)
        .map_err(|error| AssetError::Parse {
            path: path.as_str().to_string(),
            reason: format!("invalid embedded texture base64: {error}"),
        })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn browser_texture_resize_dimensions_clamps_square_to_max() {
        assert_eq!(
            browser_texture_resize_dimensions(4096, 4096, BROWSER_TEXTURE_MAX_DIMENSION_2D),
            Some((2048, 2048))
        );
    }

    #[test]
    fn browser_texture_resize_dimensions_preserves_aspect_ratio() {
        assert_eq!(
            browser_texture_resize_dimensions(4096, 2048, BROWSER_TEXTURE_MAX_DIMENSION_2D),
            Some((2048, 1024))
        );
        assert_eq!(
            browser_texture_resize_dimensions(2048, 4096, BROWSER_TEXTURE_MAX_DIMENSION_2D),
            Some((1024, 2048))
        );
    }

    #[test]
    fn browser_texture_resize_dimensions_leaves_small_images_unscaled() {
        assert_eq!(
            browser_texture_resize_dimensions(1024, 1024, BROWSER_TEXTURE_MAX_DIMENSION_2D),
            None
        );
        assert_eq!(
            browser_texture_resize_dimensions(2048, 1024, BROWSER_TEXTURE_MAX_DIMENSION_2D),
            None
        );
        assert_eq!(
            browser_texture_resize_dimensions(1024, 2048, BROWSER_TEXTURE_MAX_DIMENSION_2D),
            None
        );
    }
}