nightshade-editor 0.14.2

Interactive map editor for the Nightshade game engine
use crate::gltf_fetch::PendingGltfMulti;
use nightshade::prelude::{ehttp, serde_json};
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};

const DOWNLOAD_URL_PREFIX: &str = "https://api.sketchfab.com/v3/models/";

#[derive(Default, Clone)]
pub enum FetchStatus {
    #[default]
    Idle,
    Working(String),
    Failed(String),
}

#[derive(Deserialize)]
struct DownloadInfo {
    #[serde(default)]
    gltf: Option<DownloadFile>,
}

#[derive(Deserialize)]
struct DownloadFile {
    url: String,
}

pub struct SketchfabBrowser {
    pub token: String,
    pub url: String,
    status: Arc<Mutex<FetchStatus>>,
    pending_gltf: Arc<Mutex<Option<PendingGltfMulti>>>,
}

impl Default for SketchfabBrowser {
    fn default() -> Self {
        Self {
            token: String::new(),
            url: String::new(),
            status: Arc::new(Mutex::new(FetchStatus::Idle)),
            pending_gltf: Arc::new(Mutex::new(None)),
        }
    }
}

impl SketchfabBrowser {
    pub fn take_pending_gltf(&mut self) -> Option<PendingGltfMulti> {
        self.pending_gltf.lock().ok().and_then(|mut p| p.take())
    }

    pub fn status(&self) -> FetchStatus {
        self.status.lock().unwrap().clone()
    }

    pub fn can_fetch(&self) -> bool {
        let working = matches!(&*self.status.lock().unwrap(), FetchStatus::Working(_));
        !working && !self.token.trim().is_empty() && !self.url.trim().is_empty()
    }

    pub fn start_fetch(&self) {
        let Some(uid) = extract_uid(&self.url) else {
            *self.status.lock().unwrap() =
                FetchStatus::Failed("could not parse model UID from URL".to_string());
            return;
        };
        let token = self.token.trim().to_string();
        if token.is_empty() {
            return;
        }

        *self.status.lock().unwrap() = FetchStatus::Working("requesting download URL".into());

        let download_endpoint = format!("{DOWNLOAD_URL_PREFIX}{uid}/download");
        let mut request = ehttp::Request::get(&download_endpoint);
        request
            .headers
            .insert("Authorization", format!("Token {token}"));

        let status = Arc::clone(&self.status);
        let pending = Arc::clone(&self.pending_gltf);
        let display_name = format!("Sketchfab {uid}");

        ehttp::fetch(request, move |result| match result {
            Ok(response) if response.ok => {
                match serde_json::from_slice::<DownloadInfo>(&response.bytes) {
                    Ok(info) => match info.gltf {
                        Some(gltf_file) => {
                            *status.lock().unwrap() =
                                FetchStatus::Working("downloading archive".into());
                            fetch_archive(gltf_file.url, display_name, status, pending);
                        }
                        None => {
                            *status.lock().unwrap() = FetchStatus::Failed(
                                "this asset does not provide a glTF download".to_string(),
                            );
                        }
                    },
                    Err(error) => {
                        *status.lock().unwrap() =
                            FetchStatus::Failed(format!("download response parse: {error}"));
                    }
                }
            }
            Ok(response) => {
                let detail = if response.status == 401 || response.status == 403 {
                    " (token rejected or asset is not downloadable for this account)"
                } else {
                    ""
                };
                *status.lock().unwrap() = FetchStatus::Failed(format!(
                    "HTTP {} on download endpoint{detail}",
                    response.status
                ));
            }
            Err(error) => {
                *status.lock().unwrap() = FetchStatus::Failed(error);
            }
        });
    }
}

fn fetch_archive(
    url: String,
    display_name: String,
    status: Arc<Mutex<FetchStatus>>,
    pending: Arc<Mutex<Option<PendingGltfMulti>>>,
) {
    let request = ehttp::Request::get(&url);
    ehttp::fetch(request, move |result| match result {
        Ok(response) if response.ok => {
            *status.lock().unwrap() = FetchStatus::Working("extracting archive".into());
            match extract_gltf_archive(&response.bytes) {
                Ok((gltf_bytes, resources)) => {
                    if let Ok(mut slot) = pending.lock() {
                        *slot = Some(PendingGltfMulti {
                            display_name,
                            gltf_bytes,
                            resources,
                        });
                    }
                    *status.lock().unwrap() = FetchStatus::Idle;
                }
                Err(error) => {
                    *status.lock().unwrap() =
                        FetchStatus::Failed(format!("archive extract: {error}"));
                }
            }
        }
        Ok(response) => {
            *status.lock().unwrap() =
                FetchStatus::Failed(format!("HTTP {} fetching archive", response.status));
        }
        Err(error) => {
            *status.lock().unwrap() = FetchStatus::Failed(error);
        }
    });
}

type ExtractedGltf = (Vec<u8>, HashMap<String, Vec<u8>>);

fn extract_gltf_archive(bytes: &[u8]) -> Result<ExtractedGltf, String> {
    use std::io::Read;

    let cursor = std::io::Cursor::new(bytes);
    let mut archive = zip::ZipArchive::new(cursor).map_err(|error| error.to_string())?;

    let mut gltf_index: Option<usize> = None;
    let mut gltf_path = String::new();
    for index in 0..archive.len() {
        let entry = archive.by_index(index).map_err(|error| error.to_string())?;
        if entry.is_dir() {
            continue;
        }
        let name = entry.name().to_string();
        let lower = name.to_lowercase();
        if lower.ends_with(".gltf") || lower.ends_with(".glb") {
            gltf_index = Some(index);
            gltf_path = name;
            break;
        }
    }

    let gltf_index = gltf_index.ok_or_else(|| "no .gltf or .glb entry in archive".to_string())?;

    let mut gltf_bytes = Vec::new();
    {
        let mut entry = archive
            .by_index(gltf_index)
            .map_err(|error| error.to_string())?;
        entry
            .read_to_end(&mut gltf_bytes)
            .map_err(|error| error.to_string())?;
    }

    let prefix = gltf_path
        .rsplit_once('/')
        .map(|(parent, _)| format!("{parent}/"))
        .unwrap_or_default();

    let mut resources = HashMap::new();
    for index in 0..archive.len() {
        if index == gltf_index {
            continue;
        }
        let mut entry = archive.by_index(index).map_err(|error| error.to_string())?;
        if entry.is_dir() {
            continue;
        }
        let name = entry.name().to_string();
        let key = name.strip_prefix(&prefix).unwrap_or(&name).to_string();
        let mut bytes = Vec::new();
        entry
            .read_to_end(&mut bytes)
            .map_err(|error| error.to_string())?;
        resources.insert(key, bytes);
    }

    Ok((gltf_bytes, resources))
}

fn extract_uid(url: &str) -> Option<String> {
    let chars: Vec<char> = url.chars().collect();
    let mut index = 0;
    while index + 32 <= chars.len() {
        let window = &chars[index..index + 32];
        if window.iter().all(|c| c.is_ascii_hexdigit()) {
            let before_ok = index == 0 || !chars[index - 1].is_ascii_hexdigit();
            let after_ok = index + 32 == chars.len() || !chars[index + 32].is_ascii_hexdigit();
            if before_ok && after_ok {
                return Some(window.iter().collect());
            }
        }
        index += 1;
    }
    None
}