9eead719b0
Full project snapshot migrated to new Gitea remote without history: engine, editor, physics, script, examples, tests, docs, and assets. Relicensed from GPLv3 to MIT and updated repo URLs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
767 lines
29 KiB
Rust
767 lines
29 KiB
Rust
//! [`AssetDatabase`]: stable, project-relative asset references.
|
|
//!
|
|
//! The [`AssetServer`](super::AssetServer) loads assets by *path*, but a scene
|
|
//! or UI document must not bake **absolute** system paths into its saved data —
|
|
//! that would break the moment the project is moved to another machine or
|
|
//! directory, and it is the chief obstacle to a clean game export (Stage 16).
|
|
//!
|
|
//! The asset database is the bridge. It assigns every imported asset a stable
|
|
//! [`AssetUid`] and records, per project, the mapping
|
|
//! **`AssetUid` ↔ assets-relative path** (e.g. `"fonts/Inter-Regular.ttf"`).
|
|
//! Saved documents reference assets by `AssetUid`; resolving a uid yields the
|
|
//! relative path, which combined with the (possibly new) project root gives an
|
|
//! absolute path the [`AssetServer`](super::AssetServer) loads and deduplicates.
|
|
//! Because the stored mapping is purely relative, a reference resolves to the
|
|
//! same [`Handle`] across save/load **and** after the whole project directory
|
|
//! moves.
|
|
//!
|
|
//! The uid layer (rather than referencing by relative path directly) means an
|
|
//! asset can later be *renamed or moved within* the project without breaking
|
|
//! references — the uid travels with the file in the manifest.
|
|
//!
|
|
//! Assets live under typed subfolders of the project's `assets/` directory
|
|
//! ([`AssetKind`] → folder), so the database (and the editor's browser) can
|
|
//! present and filter them by type without inspecting file contents.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use super::{AssetServer, Handle};
|
|
use crate::project::ASSETS_DIR;
|
|
|
|
/// The manifest file (RON) at the project root recording the uid ↔ path map.
|
|
///
|
|
/// It sits at the root rather than inside `assets/` so a scan of the typed
|
|
/// asset folders never treats the manifest itself as an asset.
|
|
pub const ASSET_MANIFEST_FILE: &str = "assets.manifest";
|
|
|
|
/// The typed category of a project asset.
|
|
///
|
|
/// A kind fixes the asset's subfolder under `assets/` and the file extensions
|
|
/// that belong to it, letting the database classify files by where they live
|
|
/// (with extension as a fallback for files dropped directly in `assets/`).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub enum AssetKind {
|
|
/// Text rendering fonts (`fonts/`): `.ttf`, `.otf`.
|
|
Font,
|
|
/// Images and textures (`textures/`): `.png`, `.jpg`, …
|
|
Texture,
|
|
/// 3D models (`models/`): `.gltf`, `.glb`, `.obj`.
|
|
Model,
|
|
/// Sound and music (`audio/`): `.wav`, `.ogg`, …
|
|
Audio,
|
|
/// Serialized UI documents (`ui/`).
|
|
Ui,
|
|
/// Game-logic scripts (`scripts/`): `.rhai` (Stage 10).
|
|
Script,
|
|
/// Anything that does not fall into a known typed folder or extension.
|
|
Other,
|
|
}
|
|
|
|
impl AssetKind {
|
|
/// The typed kinds in their canonical order (excludes [`Other`](Self::Other),
|
|
/// which has no folder of its own).
|
|
pub const TYPED: [AssetKind; 6] = [
|
|
AssetKind::Font,
|
|
AssetKind::Texture,
|
|
AssetKind::Model,
|
|
AssetKind::Audio,
|
|
AssetKind::Ui,
|
|
AssetKind::Script,
|
|
];
|
|
|
|
/// The subfolder name under `assets/` for this kind (empty for
|
|
/// [`Other`](Self::Other), which has no dedicated folder).
|
|
pub fn folder(self) -> &'static str {
|
|
match self {
|
|
AssetKind::Font => "fonts",
|
|
AssetKind::Texture => "textures",
|
|
AssetKind::Model => "models",
|
|
AssetKind::Audio => "audio",
|
|
AssetKind::Ui => "ui",
|
|
AssetKind::Script => "scripts",
|
|
AssetKind::Other => "",
|
|
}
|
|
}
|
|
|
|
/// The lower-case file extensions (without the dot) that belong to this
|
|
/// kind. [`Other`](Self::Other) claims none.
|
|
pub fn extensions(self) -> &'static [&'static str] {
|
|
match self {
|
|
AssetKind::Font => &["ttf", "otf"],
|
|
AssetKind::Texture => &["png", "jpg", "jpeg", "tga", "bmp", "dds", "ktx2"],
|
|
AssetKind::Model => &["gltf", "glb", "obj"],
|
|
AssetKind::Audio => &["wav", "ogg", "mp3", "flac"],
|
|
// UI documents share the `.ron` extension with scenes, so a UI asset
|
|
// is recognised by its `ui/` folder rather than its extension.
|
|
AssetKind::Ui => &[],
|
|
AssetKind::Script => &["rhai"],
|
|
AssetKind::Other => &[],
|
|
}
|
|
}
|
|
|
|
/// The kind owning the typed `folder` name, if any.
|
|
pub fn from_folder(folder: &str) -> Option<AssetKind> {
|
|
AssetKind::TYPED.into_iter().find(|k| k.folder() == folder)
|
|
}
|
|
|
|
/// The kind that claims `extension` (without the dot, any case), if any.
|
|
pub fn from_extension(extension: &str) -> Option<AssetKind> {
|
|
let ext = extension.to_lowercase();
|
|
AssetKind::TYPED
|
|
.into_iter()
|
|
.find(|k| k.extensions().contains(&ext.as_str()))
|
|
}
|
|
|
|
/// Classifies an assets-relative path. The leading folder wins (so a file in
|
|
/// `ui/` is [`Ui`](Self::Ui) regardless of extension); files outside a typed
|
|
/// folder fall back to their extension, else [`Other`](Self::Other).
|
|
pub fn classify(relative_path: &str) -> AssetKind {
|
|
if let Some((head, _)) = relative_path.split_once('/') {
|
|
if let Some(kind) = AssetKind::from_folder(head) {
|
|
return kind;
|
|
}
|
|
}
|
|
Path::new(relative_path)
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.and_then(AssetKind::from_extension)
|
|
.unwrap_or(AssetKind::Other)
|
|
}
|
|
|
|
/// The kind an asset reference of target type `target` refers to, used to
|
|
/// filter an asset picker. `target` is the inner type of an `AssetRef<T>`
|
|
/// (or `Handle<T>`) field (see [`asset_ref_target`]); unknown targets yield
|
|
/// `None` so the picker can offer every kind.
|
|
pub fn for_handle_target(target: &str) -> Option<AssetKind> {
|
|
match target {
|
|
"Font" | "UiFont" => Some(AssetKind::Font),
|
|
"GltfModel" | "Model" | "Mesh" => Some(AssetKind::Model),
|
|
"Texture" | "Image" => Some(AssetKind::Texture),
|
|
"AudioClip" | "Sound" | "Audio" => Some(AssetKind::Audio),
|
|
"UiPanel" | "UiDocument" => Some(AssetKind::Ui),
|
|
"ScriptAsset" | "Script" => Some(AssetKind::Script),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// If `type_name` is an asset-reference field spelling — [`AssetRef<T>`] (the
|
|
/// serializable reference components store) or a bare [`Handle<T>`] — returns
|
|
/// the inner target type's short name; otherwise `None`.
|
|
///
|
|
/// Reflection records a field's *syntactic* type name (see
|
|
/// [`FieldInfo::type_name`](crate::reflect::FieldInfo::type_name)), which for an
|
|
/// asset-reference field is something like `"AssetRef < Font >"` or
|
|
/// `"Handle<crate::ui::Font>"`. This normalizes whitespace, unwraps the single
|
|
/// generic argument, and strips any module path, yielding e.g. `"Font"`. The
|
|
/// editor uses it to recognise such fields and pick the right asset filter via
|
|
/// [`AssetKind::for_handle_target`].
|
|
pub fn asset_ref_target(type_name: &str) -> Option<&str> {
|
|
// Peel the wrapper structurally, trimming whitespace at each step, so both
|
|
// `"AssetRef < Font >"` and `"AssetRef<Font>"` parse and we return a borrow
|
|
// of the original string.
|
|
let t = type_name.trim();
|
|
let inner = t
|
|
.strip_prefix("AssetRef")
|
|
.or_else(|| t.strip_prefix("Handle"))?
|
|
.trim_start();
|
|
let inner = inner.strip_prefix('<')?.trim();
|
|
let inner = inner.strip_suffix('>')?.trim();
|
|
// Reject nested generics / multiple args we don't understand.
|
|
if inner.contains('<') || inner.contains(',') {
|
|
return None;
|
|
}
|
|
// Strip any module path (`crate::ui::Font` -> `Font`).
|
|
Some(inner.rsplit("::").next().unwrap_or(inner).trim())
|
|
}
|
|
|
|
/// A stable, per-project identifier for one asset.
|
|
///
|
|
/// Unlike [`AssetId`](super::AssetId) — which is process-unique and changes
|
|
/// every run — an `AssetUid` is persisted in the project's manifest and stays
|
|
/// attached to its asset across sessions, so saved references keep resolving.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
|
pub struct AssetUid(pub u64);
|
|
|
|
impl AssetUid {
|
|
/// The raw numeric value.
|
|
pub fn value(self) -> u64 {
|
|
self.0
|
|
}
|
|
}
|
|
|
|
/// A typed, serializable reference to a project asset.
|
|
///
|
|
/// This is what a **component** stores when it points at an asset (a UI label's
|
|
/// font, a renderer's mesh, …). A live [`Handle<T>`] is not serializable and is
|
|
/// tied to one process run, so persisting it would be wrong; an `AssetRef<T>`
|
|
/// instead holds the stable [`AssetUid`] and resolves to a handle on demand via
|
|
/// [`resolve`](Self::resolve) (database → relative path → server → handle).
|
|
///
|
|
/// Being a thin wrapper over `Option<AssetUid>`, it serializes compactly and
|
|
/// round-trips through reflection's RON path, so an asset-reference field is
|
|
/// editable in the inspector with no per-type code. The phantom `T` records the
|
|
/// target asset type, which the editor reads from the field's spelling
|
|
/// (`"AssetRef < Font >"`) via [`asset_ref_target`] to filter the picker.
|
|
pub struct AssetRef<T> {
|
|
uid: Option<AssetUid>,
|
|
_marker: std::marker::PhantomData<fn() -> T>,
|
|
}
|
|
|
|
impl<T> AssetRef<T> {
|
|
/// An empty reference, pointing at no asset.
|
|
pub const fn none() -> Self {
|
|
Self {
|
|
uid: None,
|
|
_marker: std::marker::PhantomData,
|
|
}
|
|
}
|
|
|
|
/// A reference to the asset with stable id `uid`.
|
|
pub const fn new(uid: AssetUid) -> Self {
|
|
Self {
|
|
uid: Some(uid),
|
|
_marker: std::marker::PhantomData,
|
|
}
|
|
}
|
|
|
|
/// The referenced asset's stable id, or `None` if empty.
|
|
pub fn uid(self) -> Option<AssetUid> {
|
|
self.uid
|
|
}
|
|
|
|
/// Whether this reference points at an asset.
|
|
pub fn is_some(self) -> bool {
|
|
self.uid.is_some()
|
|
}
|
|
|
|
/// Points the reference at `uid` (or clears it with `None`).
|
|
pub fn set(&mut self, uid: Option<AssetUid>) {
|
|
self.uid = uid;
|
|
}
|
|
|
|
/// Resolves to a loaded [`Handle<T>`] via `db` + `server`, or `None` if the
|
|
/// reference is empty or its uid is unknown to the database.
|
|
pub fn resolve(self, db: &AssetDatabase, server: &AssetServer) -> Option<Handle<T>>
|
|
where
|
|
T: Send + Sync + 'static,
|
|
{
|
|
db.load::<T>(server, self.uid?)
|
|
}
|
|
}
|
|
|
|
// Hand-written trait impls: deriving would wrongly require `T: Clone`/`Default`
|
|
// etc., but an `AssetRef<T>` carries no `T` value — only a uid + phantom marker.
|
|
impl<T> Clone for AssetRef<T> {
|
|
fn clone(&self) -> Self {
|
|
*self
|
|
}
|
|
}
|
|
impl<T> Copy for AssetRef<T> {}
|
|
impl<T> Default for AssetRef<T> {
|
|
fn default() -> Self {
|
|
Self::none()
|
|
}
|
|
}
|
|
impl<T> PartialEq for AssetRef<T> {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
self.uid == other.uid
|
|
}
|
|
}
|
|
impl<T> Eq for AssetRef<T> {}
|
|
impl<T> std::fmt::Debug for AssetRef<T> {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_tuple("AssetRef").field(&self.uid).finish()
|
|
}
|
|
}
|
|
// Serialize transparently as the inner `Option<AssetUid>` so saved data is just
|
|
// the uid (or unit `None`) and stays independent of `T`.
|
|
impl<T> Serialize for AssetRef<T> {
|
|
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
|
self.uid.serialize(serializer)
|
|
}
|
|
}
|
|
impl<'de, T> Deserialize<'de> for AssetRef<T> {
|
|
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
|
Ok(Self {
|
|
uid: Option::<AssetUid>::deserialize(deserializer)?,
|
|
_marker: std::marker::PhantomData,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// One asset's record in the database: its stable id, kind, and the path it
|
|
/// lives at *relative to the project's `assets/` directory* (forward slashes).
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct AssetEntry {
|
|
/// The stable identifier saved references use.
|
|
pub uid: AssetUid,
|
|
/// The asset's typed category.
|
|
pub kind: AssetKind,
|
|
/// Path relative to `assets/`, e.g. `"fonts/Inter-Regular.ttf"`.
|
|
pub path: String,
|
|
}
|
|
|
|
/// The on-disk manifest: the uid allocator plus every known entry.
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
struct Manifest {
|
|
/// The next uid to hand out; persisted so a deleted asset's uid is never
|
|
/// reused by a freshly imported one.
|
|
next_uid: u64,
|
|
/// Every recorded asset (sorted by uid when written, for stable diffs).
|
|
entries: Vec<AssetEntry>,
|
|
}
|
|
|
|
/// Maps stable asset ids to project-relative paths and back, and resolves them
|
|
/// to [`Handle`]s through an [`AssetServer`](super::AssetServer).
|
|
///
|
|
/// Construct it for a project root with [`new`](Self::new) (empty) or
|
|
/// [`open`](Self::open) (reading any existing manifest), then [`scan`](Self::scan)
|
|
/// the asset folders or [`register`](Self::register) individual imports. The
|
|
/// root may be changed with [`set_root`](Self::set_root) — e.g. after opening
|
|
/// the same project from a new location — without disturbing the uid mapping.
|
|
#[derive(Debug, Clone)]
|
|
pub struct AssetDatabase {
|
|
root: PathBuf,
|
|
by_uid: HashMap<AssetUid, AssetEntry>,
|
|
by_path: HashMap<String, AssetUid>,
|
|
next_uid: u64,
|
|
}
|
|
|
|
impl AssetDatabase {
|
|
/// An empty database for the project rooted at `root`.
|
|
pub fn new(root: impl AsRef<Path>) -> Self {
|
|
Self {
|
|
root: root.as_ref().to_path_buf(),
|
|
by_uid: HashMap::new(),
|
|
by_path: HashMap::new(),
|
|
next_uid: 1,
|
|
}
|
|
}
|
|
|
|
/// Opens the database for the project at `root`, reading its manifest if
|
|
/// present. A missing or unreadable manifest yields an empty database (a
|
|
/// later [`scan`](Self::scan) repopulates it from disk).
|
|
pub fn open(root: impl AsRef<Path>) -> Self {
|
|
let root = root.as_ref().to_path_buf();
|
|
let mut db = Self::new(&root);
|
|
let manifest_path = root.join(ASSET_MANIFEST_FILE);
|
|
if let Ok(text) = std::fs::read_to_string(&manifest_path) {
|
|
if let Ok(manifest) = ron::from_str::<Manifest>(&text) {
|
|
for entry in manifest.entries {
|
|
db.by_path.insert(entry.path.clone(), entry.uid);
|
|
db.by_uid.insert(entry.uid, entry);
|
|
}
|
|
db.next_uid = manifest.next_uid.max(db.highest_uid() + 1);
|
|
}
|
|
}
|
|
db
|
|
}
|
|
|
|
/// Writes the manifest to `<root>/assets.manifest`.
|
|
pub fn save(&self) -> std::io::Result<()> {
|
|
let mut entries: Vec<AssetEntry> = self.by_uid.values().cloned().collect();
|
|
entries.sort_by_key(|e| e.uid);
|
|
let manifest = Manifest {
|
|
next_uid: self.next_uid,
|
|
entries,
|
|
};
|
|
let text = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default())
|
|
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
|
std::fs::write(self.manifest_path(), text)
|
|
}
|
|
|
|
/// The project root the database resolves paths against.
|
|
pub fn root(&self) -> &Path {
|
|
&self.root
|
|
}
|
|
|
|
/// Points the database at a new project root (e.g. after the project
|
|
/// directory moved). The uid ↔ relative-path mapping is unaffected, so all
|
|
/// existing references keep resolving — now against the new location.
|
|
pub fn set_root(&mut self, root: impl AsRef<Path>) {
|
|
self.root = root.as_ref().to_path_buf();
|
|
}
|
|
|
|
/// The `assets/` directory under the project root.
|
|
pub fn assets_dir(&self) -> PathBuf {
|
|
self.root.join(ASSETS_DIR)
|
|
}
|
|
|
|
/// The manifest file path.
|
|
pub fn manifest_path(&self) -> PathBuf {
|
|
self.root.join(ASSET_MANIFEST_FILE)
|
|
}
|
|
|
|
/// Records the asset at `relative_path` (relative to `assets/`), returning
|
|
/// its uid — the existing one if already known, else a freshly allocated
|
|
/// one. The kind is inferred from the path. Idempotent for a given path.
|
|
pub fn register(&mut self, relative_path: impl AsRef<str>) -> AssetUid {
|
|
let path = normalize_relative(relative_path.as_ref());
|
|
if let Some(&uid) = self.by_path.get(&path) {
|
|
return uid;
|
|
}
|
|
let uid = AssetUid(self.next_uid);
|
|
self.next_uid += 1;
|
|
let entry = AssetEntry {
|
|
uid,
|
|
kind: AssetKind::classify(&path),
|
|
path: path.clone(),
|
|
};
|
|
self.by_path.insert(path, uid);
|
|
self.by_uid.insert(uid, entry);
|
|
uid
|
|
}
|
|
|
|
/// Scans the typed asset folders under `assets/` and reconciles the
|
|
/// database with what is on disk: existing files keep their uid, new files
|
|
/// are [registered](Self::register), and entries whose files no longer exist
|
|
/// are dropped. Returns the number of newly registered assets.
|
|
///
|
|
/// Call [`save`](Self::save) afterwards to persist any new uids.
|
|
pub fn scan(&mut self) -> usize {
|
|
let assets_dir = self.assets_dir();
|
|
let mut found: Vec<String> = Vec::new();
|
|
for kind in AssetKind::TYPED {
|
|
collect_files(&assets_dir.join(kind.folder()), &assets_dir, &mut found);
|
|
}
|
|
|
|
// Drop entries whose backing file disappeared.
|
|
let present: std::collections::HashSet<&String> = found.iter().collect();
|
|
let removed: Vec<(AssetUid, String)> = self
|
|
.by_uid
|
|
.values()
|
|
.filter(|e| !present.contains(&e.path))
|
|
.map(|e| (e.uid, e.path.clone()))
|
|
.collect();
|
|
for (uid, path) in removed {
|
|
self.by_uid.remove(&uid);
|
|
self.by_path.remove(&path);
|
|
}
|
|
|
|
// Register anything new.
|
|
let before = self.by_uid.len();
|
|
for path in found {
|
|
self.register(path);
|
|
}
|
|
self.by_uid.len().saturating_sub(before)
|
|
}
|
|
|
|
/// The entry for `uid`, if known.
|
|
pub fn entry(&self, uid: AssetUid) -> Option<&AssetEntry> {
|
|
self.by_uid.get(&uid)
|
|
}
|
|
|
|
/// The uid recorded for an assets-relative path, if any.
|
|
pub fn uid_of(&self, relative_path: impl AsRef<str>) -> Option<AssetUid> {
|
|
self.by_path
|
|
.get(&normalize_relative(relative_path.as_ref()))
|
|
.copied()
|
|
}
|
|
|
|
/// The assets-relative path for `uid`, if known.
|
|
pub fn relative_path(&self, uid: AssetUid) -> Option<&str> {
|
|
self.by_uid.get(&uid).map(|e| e.path.as_str())
|
|
}
|
|
|
|
/// The absolute filesystem path for `uid` under the current root, if known.
|
|
pub fn absolute_path(&self, uid: AssetUid) -> Option<PathBuf> {
|
|
self.by_uid.get(&uid).map(|e| {
|
|
self.assets_dir()
|
|
.join(e.path.replace('/', std::path::MAIN_SEPARATOR_STR))
|
|
})
|
|
}
|
|
|
|
/// Every entry, in unspecified order.
|
|
pub fn entries(&self) -> impl Iterator<Item = &AssetEntry> {
|
|
self.by_uid.values()
|
|
}
|
|
|
|
/// Entries of a given kind, in unspecified order.
|
|
pub fn entries_of_kind(&self, kind: AssetKind) -> impl Iterator<Item = &AssetEntry> {
|
|
self.by_uid.values().filter(move |e| e.kind == kind)
|
|
}
|
|
|
|
/// The number of recorded assets.
|
|
pub fn len(&self) -> usize {
|
|
self.by_uid.len()
|
|
}
|
|
|
|
/// Whether the database has no entries.
|
|
pub fn is_empty(&self) -> bool {
|
|
self.by_uid.is_empty()
|
|
}
|
|
|
|
/// Resolves `uid` to a loaded [`Handle<T>`] via `server`, or `None` if the
|
|
/// uid is unknown. The handle is deduplicated by the server, so resolving
|
|
/// the same uid (even after the project moved) yields the same asset.
|
|
pub fn load<T: Send + Sync + 'static>(
|
|
&self,
|
|
server: &AssetServer,
|
|
uid: AssetUid,
|
|
) -> Option<Handle<T>> {
|
|
let path = self.absolute_path(uid)?;
|
|
Some(server.load::<T>(path))
|
|
}
|
|
|
|
// --- internals ---------------------------------------------------------
|
|
|
|
fn highest_uid(&self) -> u64 {
|
|
self.by_uid.keys().map(|u| u.0).max().unwrap_or(0)
|
|
}
|
|
}
|
|
|
|
/// Normalizes a path to the database's canonical relative form: forward slashes,
|
|
/// no leading `./` or separator.
|
|
fn normalize_relative(path: &str) -> String {
|
|
let trimmed = path.replace('\\', "/");
|
|
let trimmed = trimmed.strip_prefix("./").unwrap_or(&trimmed);
|
|
trimmed.trim_start_matches('/').to_string()
|
|
}
|
|
|
|
/// Recursively collects files under `dir`, pushing each one's path relative to
|
|
/// `base` (forward slashes) into `out`. A missing `dir` is silently skipped.
|
|
fn collect_files(dir: &Path, base: &Path, out: &mut Vec<String>) {
|
|
let Ok(read) = std::fs::read_dir(dir) else {
|
|
return;
|
|
};
|
|
for entry in read.flatten() {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
collect_files(&path, base, out);
|
|
} else if let Ok(rel) = path.strip_prefix(base) {
|
|
out.push(rel.to_string_lossy().replace('\\', "/"));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
|
|
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
|
|
|
fn temp_root(tag: &str) -> PathBuf {
|
|
let mut path = std::env::temp_dir();
|
|
path.push(format!(
|
|
"oxide_assetdb_test_{}_{}_{tag}",
|
|
std::process::id(),
|
|
COUNTER.fetch_add(1, Ordering::SeqCst),
|
|
));
|
|
path
|
|
}
|
|
|
|
/// Creates `assets/<rel>` under `root` with placeholder contents.
|
|
fn touch_asset(root: &Path, rel: &str) {
|
|
let full = root.join(ASSETS_DIR).join(rel);
|
|
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
|
|
std::fs::write(full, b"x").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn classify_by_folder_then_extension() {
|
|
assert_eq!(AssetKind::classify("fonts/Inter.ttf"), AssetKind::Font);
|
|
assert_eq!(AssetKind::classify("ui/menu.ron"), AssetKind::Ui);
|
|
assert_eq!(AssetKind::classify("textures/wall.png"), AssetKind::Texture);
|
|
// No typed folder → fall back to extension.
|
|
assert_eq!(AssetKind::classify("loose.glb"), AssetKind::Model);
|
|
assert_eq!(AssetKind::classify("notes.md"), AssetKind::Other);
|
|
}
|
|
|
|
#[test]
|
|
fn asset_ref_target_parses_and_maps_to_kind() {
|
|
// The derive's spelling (spaces around the generic args), for both the
|
|
// serializable `AssetRef<T>` and a bare `Handle<T>`.
|
|
assert_eq!(asset_ref_target("AssetRef < Font >"), Some("Font"));
|
|
assert_eq!(asset_ref_target("Handle < Font >"), Some("Font"));
|
|
// Compact and module-qualified spellings.
|
|
assert_eq!(asset_ref_target("AssetRef<GltfModel>"), Some("GltfModel"));
|
|
assert_eq!(asset_ref_target("Handle<crate::ui::Font>"), Some("Font"));
|
|
// Non-reference and unsupported (nested / multi-arg) fields.
|
|
assert_eq!(asset_ref_target("f32"), None);
|
|
assert_eq!(asset_ref_target("Vec<AssetRef<Font>>"), None);
|
|
assert_eq!(asset_ref_target("HashMap<String, u32>"), None);
|
|
|
|
// Target type -> picker filter kind.
|
|
assert_eq!(AssetKind::for_handle_target("Font"), Some(AssetKind::Font));
|
|
assert_eq!(
|
|
AssetKind::for_handle_target("GltfModel"),
|
|
Some(AssetKind::Model)
|
|
);
|
|
assert_eq!(AssetKind::for_handle_target("Whatever"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn asset_ref_serializes_as_uid_and_resolves() {
|
|
// Empty and populated references round-trip through RON as just the uid.
|
|
let empty = AssetRef::<String>::none();
|
|
assert!(!empty.is_some());
|
|
let ron_empty = ron::to_string(&empty).unwrap();
|
|
assert_eq!(
|
|
ron::from_str::<AssetRef<String>>(&ron_empty).unwrap(),
|
|
empty
|
|
);
|
|
|
|
let r = AssetRef::<String>::new(AssetUid(7));
|
|
let round: AssetRef<String> = ron::from_str(&ron::to_string(&r).unwrap()).unwrap();
|
|
assert_eq!(round.uid(), Some(AssetUid(7)));
|
|
|
|
// resolve() goes ref -> db -> server -> handle.
|
|
let root = temp_root("assetref");
|
|
let full = root.join(ASSETS_DIR).join("textures");
|
|
std::fs::create_dir_all(&full).unwrap();
|
|
std::fs::write(full.join("a.txt"), "hi").unwrap();
|
|
let mut db = AssetDatabase::new(&root);
|
|
let uid = db.register("textures/a.txt");
|
|
let server = AssetServer::empty();
|
|
server.register_loader(TxtLoader);
|
|
|
|
let reference = AssetRef::<String>::new(uid);
|
|
let handle = reference.resolve(&db, &server).unwrap();
|
|
assert_eq!(handle.get().unwrap().as_str(), "hi");
|
|
// An empty ref resolves to nothing.
|
|
assert!(AssetRef::<String>::none().resolve(&db, &server).is_none());
|
|
|
|
std::fs::remove_dir_all(root).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn register_is_idempotent_and_infers_kind() {
|
|
let mut db = AssetDatabase::new(temp_root("register"));
|
|
let a = db.register("fonts/Inter-Regular.ttf");
|
|
let b = db.register("fonts/Inter-Regular.ttf");
|
|
assert_eq!(a, b, "same path returns same uid");
|
|
assert_eq!(db.len(), 1);
|
|
assert_eq!(db.entry(a).unwrap().kind, AssetKind::Font);
|
|
// Path normalization: a `./`-prefixed, back-slashed spelling collapses.
|
|
assert_eq!(db.uid_of(".\\fonts\\Inter-Regular.ttf"), Some(a));
|
|
}
|
|
|
|
#[test]
|
|
fn scan_picks_up_typed_folders_and_prunes_missing() {
|
|
let root = temp_root("scan");
|
|
touch_asset(&root, "fonts/Inter.ttf");
|
|
touch_asset(&root, "textures/wall.png");
|
|
touch_asset(&root, "models/cube.glb");
|
|
|
|
let mut db = AssetDatabase::new(&root);
|
|
assert_eq!(db.scan(), 3);
|
|
assert_eq!(db.len(), 3);
|
|
assert_eq!(db.entries_of_kind(AssetKind::Font).count(), 1);
|
|
|
|
// Remove one file and rescan: it is pruned, the rest keep their uids.
|
|
let font_uid = db.uid_of("fonts/Inter.ttf").unwrap();
|
|
let wall_uid = db.uid_of("textures/wall.png").unwrap();
|
|
std::fs::remove_file(root.join(ASSETS_DIR).join("fonts/Inter.ttf")).unwrap();
|
|
assert_eq!(db.scan(), 0);
|
|
assert_eq!(db.len(), 2);
|
|
assert!(db.entry(font_uid).is_none());
|
|
assert_eq!(db.uid_of("textures/wall.png"), Some(wall_uid));
|
|
|
|
std::fs::remove_dir_all(root).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn manifest_round_trips_uids() {
|
|
let root = temp_root("manifest");
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
touch_asset(&root, "fonts/Inter.ttf");
|
|
touch_asset(&root, "audio/click.wav");
|
|
|
|
let mut db = AssetDatabase::new(&root);
|
|
db.scan();
|
|
let font_uid = db.uid_of("fonts/Inter.ttf").unwrap();
|
|
let click_uid = db.uid_of("audio/click.wav").unwrap();
|
|
let next = db.next_uid;
|
|
db.save().unwrap();
|
|
|
|
// Reload from the manifest: every uid is preserved, and the allocator
|
|
// does not reuse a freed id.
|
|
let reloaded = AssetDatabase::open(&root);
|
|
assert_eq!(reloaded.uid_of("fonts/Inter.ttf"), Some(font_uid));
|
|
assert_eq!(reloaded.uid_of("audio/click.wav"), Some(click_uid));
|
|
assert_eq!(reloaded.next_uid, next);
|
|
|
|
std::fs::remove_dir_all(root).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn reference_survives_save_load_and_moved_project() {
|
|
// A reference (uid) saved with the project must resolve to the same
|
|
// handle after reload AND after the whole project directory moves.
|
|
let root = temp_root("move_src");
|
|
touch_asset(&root, "fonts/Inter.ttf");
|
|
let mut db = AssetDatabase::open(&root);
|
|
db.scan();
|
|
db.save().unwrap();
|
|
let uid = db.uid_of("fonts/Inter.ttf").unwrap();
|
|
|
|
// Simulate moving the project to a new directory on disk.
|
|
let moved = temp_root("move_dst");
|
|
std::fs::create_dir_all(&moved).unwrap();
|
|
copy_dir(&root, &moved);
|
|
|
|
// Open the database from the new location: same uid, new absolute path.
|
|
let moved_db = AssetDatabase::open(&moved);
|
|
assert_eq!(moved_db.uid_of("fonts/Inter.ttf"), Some(uid));
|
|
let abs = moved_db.absolute_path(uid).unwrap();
|
|
assert!(abs.starts_with(&moved));
|
|
assert!(abs.exists());
|
|
|
|
std::fs::remove_dir_all(root).ok();
|
|
std::fs::remove_dir_all(moved).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn load_dedups_through_the_server() {
|
|
// Use a tiny custom loader so we don't need a real asset format.
|
|
let root = temp_root("load");
|
|
let full = root.join(ASSETS_DIR).join("textures");
|
|
std::fs::create_dir_all(&full).unwrap();
|
|
std::fs::write(full.join("a.txt"), "hi").unwrap();
|
|
|
|
let mut db = AssetDatabase::new(&root);
|
|
let uid = db.register("textures/a.txt");
|
|
|
|
let server = AssetServer::empty();
|
|
server.register_loader(TxtLoader);
|
|
let h1 = db.load::<String>(&server, uid).unwrap();
|
|
let h2 = db.load::<String>(&server, uid).unwrap();
|
|
assert_eq!(h1.id(), h2.id(), "same uid resolves to one shared asset");
|
|
assert_eq!(h1.get().unwrap().as_str(), "hi");
|
|
assert!(db.load::<String>(&server, AssetUid(999)).is_none());
|
|
|
|
std::fs::remove_dir_all(root).ok();
|
|
}
|
|
|
|
struct TxtLoader;
|
|
impl crate::asset::AssetLoader for TxtLoader {
|
|
type Asset = String;
|
|
fn extensions(&self) -> &'static [&'static str] {
|
|
&["txt"]
|
|
}
|
|
fn load(&self, path: &Path) -> Result<String, crate::asset::AssetError> {
|
|
std::fs::read_to_string(path).map_err(|e| crate::asset::AssetError::Load {
|
|
path: path.to_path_buf(),
|
|
message: e.to_string(),
|
|
})
|
|
}
|
|
}
|
|
|
|
fn copy_dir(from: &Path, to: &Path) {
|
|
for entry in std::fs::read_dir(from).unwrap().flatten() {
|
|
let dst = to.join(entry.file_name());
|
|
if entry.path().is_dir() {
|
|
std::fs::create_dir_all(&dst).unwrap();
|
|
copy_dir(&entry.path(), &dst);
|
|
} else {
|
|
std::fs::copy(entry.path(), dst).unwrap();
|
|
}
|
|
}
|
|
}
|
|
}
|