nightshade-editor 0.14.2

Interactive map editor for the Nightshade game engine
use nightshade::prelude::ehttp;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};

pub struct PendingGltfMulti {
    pub display_name: String,
    pub gltf_bytes: Vec<u8>,
    pub resources: HashMap<String, Vec<u8>>,
}

struct DownloadProgress {
    gltf_bytes: Option<Vec<u8>>,
    resources: HashMap<String, Vec<u8>>,
    expected_resources: usize,
    failed: bool,
}

/// Fetches a `.gltf` plus a set of named resource URLs in parallel and
/// publishes the result into `pending` once all parts arrive. Resource
/// keys are paths relative to the `.gltf` (i.e. they match the URIs the
/// glTF JSON references).
pub fn start_multi_fetch(
    gltf_url: String,
    resources: Vec<(String, String)>,
    display_name: String,
    pending: Arc<Mutex<Option<PendingGltfMulti>>>,
    loading: Arc<Mutex<Option<String>>>,
) {
    let progress = Arc::new(Mutex::new(DownloadProgress {
        gltf_bytes: None,
        resources: HashMap::new(),
        expected_resources: resources.len(),
        failed: false,
    }));

    {
        let progress = Arc::clone(&progress);
        let pending = Arc::clone(&pending);
        let loading = Arc::clone(&loading);
        let display = display_name.clone();
        ehttp::fetch(
            ehttp::Request::get(&gltf_url),
            move |result: ehttp::Result<ehttp::Response>| {
                let bytes = result.ok().filter(|r| r.ok).map(|r| r.bytes);
                let mut prog = progress.lock().unwrap();
                match bytes {
                    Some(b) => prog.gltf_bytes = Some(b),
                    None => prog.failed = true,
                }
                try_finalize(&mut prog, &pending, &loading, display.clone());
            },
        );
    }

    for (key, url) in resources {
        let progress = Arc::clone(&progress);
        let pending = Arc::clone(&pending);
        let loading = Arc::clone(&loading);
        let display = display_name.clone();
        ehttp::fetch(
            ehttp::Request::get(&url),
            move |result: ehttp::Result<ehttp::Response>| {
                let bytes = result.ok().filter(|r| r.ok).map(|r| r.bytes);
                let mut prog = progress.lock().unwrap();
                match bytes {
                    Some(b) => {
                        prog.resources.insert(key.clone(), b);
                    }
                    None => prog.failed = true,
                }
                try_finalize(&mut prog, &pending, &loading, display.clone());
            },
        );
    }
}

fn try_finalize(
    progress: &mut DownloadProgress,
    pending: &Arc<Mutex<Option<PendingGltfMulti>>>,
    loading: &Arc<Mutex<Option<String>>>,
    display_name: String,
) {
    if progress.failed {
        if let Ok(mut state) = loading.lock() {
            *state = None;
        }
        return;
    }
    if progress.gltf_bytes.is_none() {
        return;
    }
    if progress.resources.len() < progress.expected_resources {
        return;
    }
    let gltf_bytes = progress.gltf_bytes.take().unwrap();
    let resources = std::mem::take(&mut progress.resources);
    if let Ok(mut slot) = pending.lock() {
        *slot = Some(PendingGltfMulti {
            display_name,
            gltf_bytes,
            resources,
        });
    }
    if let Ok(mut state) = loading.lock() {
        *state = None;
    }
}

/// Decodes percent-encoded characters (`%XX`) into bytes. Mirrors the
/// engine's behaviour so resource map keys can be matched against the
/// percent-decoded form the importer looks up.
pub fn percent_decode(input: &str) -> String {
    let bytes = input.as_bytes();
    let mut out = Vec::with_capacity(bytes.len());
    let mut index = 0;
    while index < bytes.len() {
        if bytes[index] == b'%'
            && index + 2 < bytes.len()
            && let (Some(high), Some(low)) =
                (hex_value(bytes[index + 1]), hex_value(bytes[index + 2]))
        {
            out.push((high << 4) | low);
            index += 3;
            continue;
        }
        out.push(bytes[index]);
        index += 1;
    }
    String::from_utf8(out).unwrap_or_else(|_| input.to_string())
}

fn hex_value(byte: u8) -> Option<u8> {
    match byte {
        b'0'..=b'9' => Some(byte - b'0'),
        b'a'..=b'f' => Some(byte - b'a' + 10),
        b'A'..=b'F' => Some(byte - b'A' + 10),
        _ => None,
    }
}

/// Walks a parsed glTF JSON and returns external `uri` references from
/// the `buffers` and `images` arrays. Skips `data:` URIs (already
/// embedded). Used to resolve sidecar files for non-binary glTFs.
pub fn external_uris_from_gltf(gltf_bytes: &[u8]) -> Vec<String> {
    let Ok(root) = nightshade::prelude::serde_json::from_slice::<
        nightshade::prelude::serde_json::Value,
    >(gltf_bytes) else {
        return Vec::new();
    };
    let mut uris = Vec::new();
    for section_key in ["buffers", "images"] {
        let Some(array) = root.get(section_key).and_then(|v| v.as_array()) else {
            continue;
        };
        for item in array {
            if let Some(uri) = item.get("uri").and_then(|v| v.as_str())
                && !uri.starts_with("data:")
            {
                uris.push(uri.to_string());
            }
        }
    }
    uris
}