Import Oxide engine (Stages 0–10) under MIT license
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>
This commit is contained in:
@@ -0,0 +1,766 @@
|
||||
//! [`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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user