#[cfg(not(target_arch = "wasm32"))]
use std::path::{Path, PathBuf};
#[cfg(not(target_arch = "wasm32"))]
use std::sync::atomic::{AtomicU64, Ordering};
#[cfg(not(target_arch = "wasm32"))]
use std::sync::{Arc, Mutex};
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Clone)]
pub struct KenneyEntry {
pub category: String,
pub pack: String,
pub display_name: String,
pub asset_name: String,
pub glb_path: PathBuf,
pub preview_path: Option<PathBuf>,
}
#[cfg(not(target_arch = "wasm32"))]
pub struct PendingGlb {
pub display_name: String,
pub path: PathBuf,
}
#[cfg(not(target_arch = "wasm32"))]
pub enum ScanState {
Idle,
Scanning(String),
Loaded(Arc<Vec<KenneyEntry>>),
Failed(String),
}
#[cfg(not(target_arch = "wasm32"))]
pub struct KenneyBrowser {
state: Arc<Mutex<ScanState>>,
root: Arc<Mutex<Option<PathBuf>>>,
pending_glbs: Arc<Mutex<Vec<PendingGlb>>>,
queued_thumbs: Arc<Mutex<bool>>,
pub generation: Arc<AtomicU64>,
dialog_in_flight: Arc<Mutex<bool>>,
}
#[cfg(not(target_arch = "wasm32"))]
impl Default for KenneyBrowser {
fn default() -> Self {
Self {
state: Arc::new(Mutex::new(ScanState::Idle)),
root: Arc::new(Mutex::new(None)),
pending_glbs: Arc::new(Mutex::new(Vec::new())),
queued_thumbs: Arc::new(Mutex::new(false)),
generation: Arc::new(AtomicU64::new(0)),
dialog_in_flight: Arc::new(Mutex::new(false)),
}
}
}
#[cfg(not(target_arch = "wasm32"))]
impl KenneyBrowser {
pub fn root(&self) -> Option<PathBuf> {
self.root.lock().ok().and_then(|guard| guard.clone())
}
pub fn entries(&self) -> Option<Arc<Vec<KenneyEntry>>> {
let guard = self.state.lock().ok()?;
if let ScanState::Loaded(entries) = &*guard {
Some(Arc::clone(entries))
} else {
None
}
}
pub fn status_text(&self) -> String {
let guard = match self.state.lock() {
Ok(guard) => guard,
Err(_) => return "scan state unavailable".to_string(),
};
match &*guard {
ScanState::Idle => "Pick a Kenney directory or .zip to begin".to_string(),
ScanState::Scanning(message) => format!("Scanning: {message}"),
ScanState::Loaded(entries) => {
let pack_count = entries
.iter()
.map(|entry| (&entry.category, &entry.pack))
.collect::<std::collections::BTreeSet<_>>()
.len();
format!("{} models across {pack_count} packs", entries.len())
}
ScanState::Failed(message) => format!("failed: {message}"),
}
}
pub fn is_scanning(&self) -> bool {
matches!(&*self.state.lock().unwrap(), ScanState::Scanning(_))
}
pub fn is_busy(&self) -> bool {
self.is_scanning() || *self.dialog_in_flight.lock().unwrap()
}
pub fn drain_pending_glbs(&self) -> Vec<PendingGlb> {
self.pending_glbs
.lock()
.map(|mut queue| std::mem::take(&mut *queue))
.unwrap_or_default()
}
pub fn pick_directory(&self) {
if self.is_scanning() {
return;
}
if let Ok(mut flag) = self.dialog_in_flight.lock() {
if *flag {
return;
}
*flag = true;
}
let dialog_flag = Arc::clone(&self.dialog_in_flight);
let root_slot = Arc::clone(&self.root);
let queued_flag = Arc::clone(&self.queued_thumbs);
let state_slot = Arc::clone(&self.state);
let generation = Arc::clone(&self.generation);
std::thread::spawn(move || {
let picked = nightshade::prelude::rfd::FileDialog::new()
.set_title("Select Kenney assets root")
.pick_folder();
if let Ok(mut flag) = dialog_flag.lock() {
*flag = false;
}
if let Some(path) = picked {
begin_root(path, &root_slot, &queued_flag, &state_slot, &generation);
}
});
}
pub fn pick_zip(&self) {
if self.is_scanning() {
return;
}
if let Ok(mut flag) = self.dialog_in_flight.lock() {
if *flag {
return;
}
*flag = true;
}
let dialog_flag = Arc::clone(&self.dialog_in_flight);
let root_slot = Arc::clone(&self.root);
let queued_flag = Arc::clone(&self.queued_thumbs);
let state_slot = Arc::clone(&self.state);
let generation = Arc::clone(&self.generation);
std::thread::spawn(move || {
let picked = nightshade::prelude::rfd::FileDialog::new()
.set_title("Select Kenney .zip archive")
.add_filter("Zip archive", &["zip"])
.pick_file();
if let Ok(mut flag) = dialog_flag.lock() {
*flag = false;
}
if let Some(zip_path) = picked {
begin_zip(zip_path, &root_slot, &queued_flag, &state_slot, &generation);
}
});
}
pub fn queue_thumbnails(&self, queue: &nightshade::prelude::SharedTextureQueue) {
let Some(entries) = self.entries() else {
return;
};
let mut already_queued = match self.queued_thumbs.lock() {
Ok(guard) => guard,
Err(_) => return,
};
if *already_queued {
return;
}
let Ok(mut q) = queue.lock() else {
return;
};
for entry in entries.iter() {
let Some(preview) = entry.preview_path.as_ref() else {
continue;
};
let name = format!("kenney_thumb_{}", entry.asset_name);
q.queue_texture(name, preview.to_string_lossy().to_string());
}
*already_queued = true;
}
}
#[cfg(not(target_arch = "wasm32"))]
fn begin_root(
path: PathBuf,
root_slot: &Arc<Mutex<Option<PathBuf>>>,
queued_flag: &Arc<Mutex<bool>>,
state_slot: &Arc<Mutex<ScanState>>,
generation: &Arc<AtomicU64>,
) {
let token = generation.fetch_add(1, Ordering::Relaxed) + 1;
if let Ok(mut slot) = root_slot.lock() {
*slot = Some(path.clone());
}
if let Ok(mut flag) = queued_flag.lock() {
*flag = false;
}
start_scan(path, state_slot, generation, token);
}
#[cfg(not(target_arch = "wasm32"))]
fn begin_zip(
zip_path: PathBuf,
root_slot: &Arc<Mutex<Option<PathBuf>>>,
queued_flag: &Arc<Mutex<bool>>,
state_slot: &Arc<Mutex<ScanState>>,
generation: &Arc<AtomicU64>,
) {
let token = generation.fetch_add(1, Ordering::Relaxed) + 1;
let display = zip_path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| zip_path.display().to_string());
if let Ok(mut slot) = state_slot.lock() {
*slot = ScanState::Scanning(format!("extracting {display}"));
}
let state_clone = Arc::clone(state_slot);
let root_clone = Arc::clone(root_slot);
let queued_clone = Arc::clone(queued_flag);
let generation_clone = Arc::clone(generation);
std::thread::spawn(move || match extract_zip(&zip_path) {
Ok(extracted_root) => {
if generation_clone.load(Ordering::Relaxed) != token {
return;
}
if let Ok(mut slot) = root_clone.lock() {
*slot = Some(extracted_root.clone());
}
if let Ok(mut flag) = queued_clone.lock() {
*flag = false;
}
run_scan(&extracted_root, &state_clone, &generation_clone, token);
}
Err(error) => {
if generation_clone.load(Ordering::Relaxed) != token {
return;
}
if let Ok(mut slot) = state_clone.lock() {
*slot = ScanState::Failed(error);
}
}
});
}
#[cfg(not(target_arch = "wasm32"))]
fn start_scan(
root: PathBuf,
state_slot: &Arc<Mutex<ScanState>>,
generation: &Arc<AtomicU64>,
token: u64,
) {
let display = root
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| root.display().to_string());
if let Ok(mut slot) = state_slot.lock() {
*slot = ScanState::Scanning(format!("scanning {display}"));
}
let target = Arc::clone(state_slot);
let generation_clone = Arc::clone(generation);
std::thread::spawn(move || {
run_scan(&root, &target, &generation_clone, token);
});
}
#[cfg(not(target_arch = "wasm32"))]
fn run_scan(root: &Path, state: &Arc<Mutex<ScanState>>, generation: &Arc<AtomicU64>, token: u64) {
let entries = match collect_entries(root) {
Ok(entries) => entries,
Err(error) => {
if generation.load(Ordering::Relaxed) != token {
return;
}
if let Ok(mut slot) = state.lock() {
*slot = ScanState::Failed(error);
}
return;
}
};
if generation.load(Ordering::Relaxed) != token {
return;
}
if entries.is_empty() {
if let Ok(mut slot) = state.lock() {
*slot = ScanState::Failed("no GLB models found at the selected location".to_string());
}
return;
}
if let Ok(mut slot) = state.lock() {
*slot = ScanState::Loaded(Arc::new(entries));
}
}
#[cfg(not(target_arch = "wasm32"))]
fn collect_entries(root: &Path) -> Result<Vec<KenneyEntry>, String> {
if !root.is_dir() {
return Err(format!("not a directory: {}", root.display()));
}
let mut entries = Vec::new();
if root.join("Models").join("GLB format").is_dir() {
let pack_name = root
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("Pack")
.to_string();
collect_pack("", &pack_name, root, &mut entries);
} else {
let category_roots = candidate_category_roots(root);
for (category, category_path) in category_roots {
let pack_iter = match std::fs::read_dir(&category_path) {
Ok(iter) => iter,
Err(_) => continue,
};
for pack_entry in pack_iter.flatten() {
let pack_path = pack_entry.path();
if !pack_path.is_dir() {
continue;
}
let pack_name = pack_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("")
.to_string();
if pack_name.is_empty() {
continue;
}
collect_pack(&category, &pack_name, &pack_path, &mut entries);
}
}
}
entries.sort_by(|left, right| {
left.category
.cmp(&right.category)
.then(left.pack.cmp(&right.pack))
.then(left.asset_name.cmp(&right.asset_name))
});
Ok(entries)
}
#[cfg(not(target_arch = "wasm32"))]
fn candidate_category_roots(root: &Path) -> Vec<(String, PathBuf)> {
let three_d = root.join("3D assets");
if three_d.is_dir() {
return vec![("3D assets".to_string(), three_d)];
}
let mut categories = Vec::new();
let mut treat_root_as_category = false;
if let Ok(iter) = std::fs::read_dir(root) {
for entry in iter.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
if path.join("Models").join("GLB format").is_dir() {
treat_root_as_category = true;
break;
}
}
}
if treat_root_as_category {
categories.push(("".to_string(), root.to_path_buf()));
return categories;
}
if let Ok(iter) = std::fs::read_dir(root) {
for entry in iter.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("")
.to_string();
if name.is_empty() {
continue;
}
categories.push((name, path));
}
}
categories
}
#[cfg(not(target_arch = "wasm32"))]
fn collect_pack(category: &str, pack: &str, pack_path: &Path, entries: &mut Vec<KenneyEntry>) {
let glb_dir = pack_path.join("Models").join("GLB format");
if !glb_dir.is_dir() {
return;
}
let preview_dir = pack_path.join("Previews");
let glb_iter = match std::fs::read_dir(&glb_dir) {
Ok(iter) => iter,
Err(_) => return,
};
for glb_entry in glb_iter.flatten() {
let glb_path = glb_entry.path();
if !glb_path.is_file() {
continue;
}
let extension = glb_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("");
if !extension.eq_ignore_ascii_case("glb") {
continue;
}
let stem = glb_path
.file_stem()
.and_then(|name| name.to_str())
.unwrap_or("")
.to_string();
if stem.is_empty() {
continue;
}
let preview_path = if preview_dir.is_dir() {
let candidate = preview_dir.join(format!("{stem}.png"));
if candidate.is_file() {
Some(candidate)
} else {
None
}
} else {
None
};
let asset_name = format!("{category}::{pack}::{stem}");
let display_name = format!("{pack} / {stem}");
entries.push(KenneyEntry {
category: category.to_string(),
pack: pack.to_string(),
display_name,
asset_name,
glb_path,
preview_path,
});
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn load_entry(browser: &KenneyBrowser, entry: &KenneyEntry) {
if let Ok(mut queue) = browser.pending_glbs.lock() {
queue.push(PendingGlb {
display_name: entry.display_name.clone(),
path: entry.glb_path.clone(),
});
}
}
#[cfg(not(target_arch = "wasm32"))]
fn extract_zip(zip_path: &Path) -> Result<PathBuf, String> {
use std::io::Read;
let bytes = std::fs::read(zip_path).map_err(|error| error.to_string())?;
let cursor = std::io::Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(cursor).map_err(|error| error.to_string())?;
let stem = zip_path
.file_stem()
.and_then(|name| name.to_str())
.unwrap_or("kenney")
.to_string();
let cache_root = std::env::temp_dir()
.join("nightshade-editor")
.join("kenney")
.join(stem);
std::fs::create_dir_all(&cache_root).map_err(|error| error.to_string())?;
for index in 0..archive.len() {
let mut entry = archive.by_index(index).map_err(|error| error.to_string())?;
let raw_name = entry.name().to_string();
let relative = raw_name.replace('\\', "/");
if relative.contains("..") || relative.starts_with('/') {
continue;
}
let out_path = cache_root.join(&relative);
if entry.is_dir() {
std::fs::create_dir_all(&out_path).map_err(|error| error.to_string())?;
continue;
}
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
}
if out_path.exists()
&& let Ok(metadata) = std::fs::metadata(&out_path)
&& metadata.len() == entry.size()
{
continue;
}
let mut buffer = Vec::with_capacity(entry.size() as usize);
entry
.read_to_end(&mut buffer)
.map_err(|error| error.to_string())?;
std::fs::write(&out_path, &buffer).map_err(|error| error.to_string())?;
}
let nested_root = detect_root(&cache_root);
Ok(nested_root.unwrap_or(cache_root))
}
#[cfg(not(target_arch = "wasm32"))]
fn detect_root(start: &Path) -> Option<PathBuf> {
if start.join("3D assets").is_dir() {
return Some(start.to_path_buf());
}
let iter = std::fs::read_dir(start).ok()?;
for entry in iter.flatten() {
let path = entry.path();
if path.is_dir() && path.join("3D assets").is_dir() {
return Some(path);
}
}
None
}