use core::mem::discriminant;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use crate::Error;
use crate::mesh::{Clip, MeshData, NoClips, Part};
use crate::skybox::SkyboxData;
use crate::sound::{Encoded, SoundData};
pub(crate) use model::{NamedAnimation, NamedModel, Readable};
pub(crate) use named::{NamedMesh, NamedSlot};
pub(crate) use ogg::Stream;
pub(crate) use texture::Textures;
pub use texture::{ReliefData, ShadingData, TextureData};
pub(crate) use unresolved::{Missing, QUALIFIER, Unresolved};
#[cfg(test)]
pub(crate) const BEACON: &[u8] = include_bytes!("../../examples/assets/hello.glb");
#[cfg(test)]
pub(crate) const SWEEP: &[u8] = include_bytes!("../../tests/assets/sweep.ogg");
#[cfg(test)]
pub(crate) const RIG: &[u8] = include_bytes!("../../tests/assets/a_rig.glb");
#[cfg(test)]
pub(crate) const SCALED: &[u8] = include_bytes!("../../tests/assets/a_rig_scaled.glb");
#[cfg(test)]
pub(crate) const PROP: &[u8] = include_bytes!("../../tests/assets/b_prop.glb");
#[cfg(test)]
pub(crate) const TRACKED: &[u8] = include_bytes!("../../tests/assets/two_tracks_same_action.glb");
#[cfg(test)]
pub(crate) const MERGED: &[u8] = include_bytes!("../../tests/assets/d_merge.glb");
#[cfg(test)]
pub(crate) const ROBOT: &[u8] = include_bytes!("../../tests/assets/robot-expressive.glb");
#[cfg(test)]
pub(crate) const CORGI: &[u8] = include_bytes!("../../examples/assets/corgi.glb");
#[cfg(test)]
pub(crate) const CHEST: &[u8] = include_bytes!("../../examples/assets/chest.glb");
#[cfg(test)]
pub(crate) const IMP: &[u8] = include_bytes!("../../tests/assets/imp.png");
#[cfg(test)]
pub(crate) const OPERATOR: &[u8] = include_bytes!("../../examples/assets/pixel-operator.ttf");
#[cfg(test)]
pub(crate) fn file(source: &str, bytes: &[u8]) -> (String, Vec<u8>) {
(source.to_owned(), bytes.to_vec())
}
#[derive(Default)]
pub struct Assets {
items: HashMap<String, Vec<(Arc<str>, Item)>>,
stems: HashSet<Arc<str>>,
}
impl Assets {
pub fn mesh<P: Part>(&self, name: &str) -> MeshData<P, NoClips> {
match self.find(name, Item::mesh) {
Ok(mesh) => mesh.resolved(name),
Err(unaddressable) => MeshData::missing(Unresolved::of(unaddressable)),
}
}
pub fn model<P: Part, C: Clip>(&self, name: &str) -> MeshData<P, C> {
match self.find(name, Item::model) {
Ok(model) => model.resolved(name),
Err(unaddressable) => MeshData::missing(Unresolved::of(unaddressable)),
}
}
pub fn texture(&self, name: &str) -> TextureData {
match self.find(name, Item::texture) {
Ok(texture) => texture.clone(),
Err(unaddressable) => TextureData::missing(Unresolved::of(unaddressable)),
}
}
pub fn relief(&self, name: &str) -> ReliefData {
ReliefData::loaded(self.texture(name))
}
pub fn skybox(&self, name: &str) -> SkyboxData {
let mut image = self.texture(name);
match image.drawn() {
true => SkyboxData::equirect(image),
false => SkyboxData::missing(image.take_unresolved()),
}
}
pub fn sound(&self, name: &str) -> SoundData {
match self.find(name, Item::sound) {
Ok(clip) => SoundData::loaded(Arc::clone(clip)),
Err(unaddressable) => SoundData::missing(Unresolved::of(unaddressable)),
}
}
#[cfg(feature = "ui")]
pub(crate) fn font(&self, name: &str) -> Result<Arc<[u8]>, Error> {
match self.find(name, Item::font) {
Ok(bytes) => Ok(Arc::clone(bytes)),
Err(unaddressable) => Err(Error::msg(unaddressable.to_string())),
}
}
pub(crate) fn load(files: impl IntoIterator<Item = (String, Vec<u8>)>) -> Result<Self, Error> {
let decoded = files
.into_iter()
.map(|(source, bytes)| DecodedFile::decode(&source, &bytes));
let mut loaded = Self::default();
let mut failures = Vec::new();
for file in decoded {
if let Err(error) = file.and_then(|file| loaded.place(file)) {
failures.push(error.to_string());
}
}
match failures.is_empty() {
true => Ok(loaded),
false => Err(Error::msg(format!(
"the game's asset sources did not load: {}",
failures.join("; ")
))),
}
}
fn find<'a, T>(&'a self, name: &str, of: impl Fn(&'a Item) -> Option<T>) -> Result<T, Missing> {
let held = |bare: &str| -> Vec<(&'a Arc<str>, T)> {
self.items
.get(bare)
.into_iter()
.flatten()
.filter_map(|(stem, item)| of(item).map(|held| (stem, held)))
.collect()
};
let mut shared = held(name);
match shared.len() {
1 => return Ok(shared.remove(0).1),
0 => {}
_ => {
return Err(Missing::Ambiguous {
name: name.to_owned(),
sources: shared
.into_iter()
.map(|(stem, _)| stem.to_string())
.collect(),
});
}
}
let Some((stem, bare)) = name.split_once(QUALIFIER) else {
return Err(Missing::absent(name));
};
held(bare)
.into_iter()
.find(|(other, _)| other.as_ref() == stem)
.map(|(_, item)| item)
.ok_or_else(|| Missing::absent(name))
}
fn place(&mut self, file: DecodedFile) -> Result<(), Error> {
let DecodedFile { stem, items } = file;
if !self.stems.insert(Arc::clone(&stem)) {
return Err(shared_stem(&stem));
}
for (name, item) in items {
self.items
.entry(name)
.or_default()
.push((Arc::clone(&stem), item));
}
Ok(())
}
}
struct DecodedFile {
stem: Arc<str>,
items: Vec<(String, Item)>,
}
impl DecodedFile {
fn decode(source: &str, bytes: &[u8]) -> Result<Self, Error> {
let stem: Arc<str> = Arc::from(stem(source)?);
let named = |error| Error::msg(format!("the asset source `{source}` {error}"));
let items = match Kind::of(source) {
Kind::Model => glb::decode(&stem, bytes).map_err(named)?,
Kind::Texture => vec![(
stem.to_string(),
Item::Texture(texture::decode(bytes).map_err(named)?),
)],
Kind::Sound => vec![(
stem.to_string(),
Item::Sound(Arc::new(ogg::decode(bytes).map_err(named)?)),
)],
Kind::Font => font_items(&stem, bytes).map_err(named)?,
};
let mut names = HashSet::with_capacity(items.len());
for (name, item) in &items {
if !names.insert((name.as_str(), discriminant(item))) {
return Err(Error::msg(format!(
"the asset source `{source}` calls two things `{name}`"
)));
}
}
Ok(Self { stem, items })
}
}
#[cfg(feature = "ui")]
fn font_items(stem: &str, bytes: &[u8]) -> Result<Vec<(String, Item)>, Error> {
let font = font::decode(bytes)?;
Ok(vec![(stem.to_owned(), Item::Font(font))])
}
#[cfg(not(feature = "ui"))]
fn font_items(_stem: &str, bytes: &[u8]) -> Result<Vec<(String, Item)>, Error> {
font::decode(bytes)?;
Ok(Vec::new())
}
fn shared_stem(stem: &str) -> Error {
Error::msg(format!(
"two asset sources are both called `{stem}`, and a shared item name is reached by \
file name, so rename one of them"
))
}
enum Item {
Mesh(NamedMesh),
Model(NamedModel),
Texture(TextureData),
Sound(Arc<Encoded>),
#[cfg(feature = "ui")]
Font(Arc<[u8]>),
}
impl Item {
fn mesh(&self) -> Option<&NamedMesh> {
match self {
Self::Mesh(mesh) => Some(mesh),
_ => None,
}
}
fn model(&self) -> Option<&NamedModel> {
match self {
Self::Model(model) => Some(model),
_ => None,
}
}
fn texture(&self) -> Option<&TextureData> {
match self {
Self::Texture(texture) => Some(texture),
_ => None,
}
}
fn sound(&self) -> Option<&Arc<Encoded>> {
match self {
Self::Sound(clip) => Some(clip),
_ => None,
}
}
#[cfg(feature = "ui")]
fn font(&self) -> Option<&Arc<[u8]>> {
match self {
Self::Font(bytes) => Some(bytes),
_ => None,
}
}
}
enum Kind {
Model,
Texture,
Sound,
Font,
}
impl Kind {
fn of(source: &str) -> Self {
match source.rsplit_once('.') {
Some((_, kind)) if kind.eq_ignore_ascii_case("ogg") => Self::Sound,
Some((_, kind)) if kind.eq_ignore_ascii_case("png") => Self::Texture,
Some((_, kind))
if kind.eq_ignore_ascii_case("ttf") || kind.eq_ignore_ascii_case("otf") =>
{
Self::Font
}
_ => Self::Model,
}
}
}
fn stem(source: &str) -> Result<&str, Error> {
let file = source.rsplit(['/', '\\']).next().unwrap_or(source);
let stem = file.rsplit_once('.').map_or(file, |(stem, _)| stem);
if stem.contains(QUALIFIER) {
return Err(Error::msg(format!(
"the asset source `{stem}` holds a `{QUALIFIER}` in its file name, which is the \
mark a shared item name is read through, so rename it"
)));
}
Ok(stem)
}
pub(crate) mod font;
mod glb;
mod model;
mod named;
pub(crate) mod ogg;
#[cfg(test)]
mod testing;
mod texture;
mod unresolved;
#[cfg(test)]
mod tests {
use core::time::Duration;
use super::*;
use crate::assets::testing::unresolved;
use crate::math::UVec2;
use crate::mesh::NoParts;
fn loaded(sources: &[&str]) -> Assets {
Assets::load(sources.iter().map(|source| file(source, BEACON)))
.expect("the example's model decodes")
}
#[test]
fn the_store_is_shared_across_threads() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Assets>();
}
#[test]
fn a_miss_returns_empty_data_carrying_the_name_into_one_error() {
let assets = Assets::default();
let hull = assets.mesh::<NoParts>("hull");
let mut paint = assets.texture("paint");
assert_eq!(hull.slots().len(), 0, "a miss draws nothing");
assert_eq!(paint.size(), UVec2::ZERO, "and reads no pixels");
let mut carried = unresolved(hull);
carried.record(unresolved(assets.mesh::<NoParts>("hull")));
carried.record(paint.take_unresolved());
assert_eq!(
carried
.error()
.expect("both names came back on the data the pulls returned")
.to_string(),
"the game's assets did not resolve: no asset is named `hull`; \
no asset is named `paint`",
"one line each, however many builds pulled the name"
);
}
#[test]
fn a_name_loaded_as_one_kind_is_a_miss_as_the_other() {
let assets = loaded(&["hello.glb"]);
let mut beacon = assets.texture("beacon");
assert!(beacon.pixels().is_empty());
assert!(beacon.take_unresolved().error().is_some());
}
#[test]
fn a_name_two_sources_share_is_reachable_only_by_their_file_names() {
let assets = loaded(&["art/props.glb", "art/scene.glb"]);
let shared = assets.mesh::<NoParts>("beacon");
assert_eq!(shared.slots().len(), 0, "bare, it reaches neither of them");
assert_eq!(
unresolved(shared)
.error()
.expect("the ambiguity came back on the empty data")
.to_string(),
"the game's assets did not resolve: several sources call something `beacon`; \
ask for `props#beacon` or `scene#beacon`"
);
let qualified = assets.mesh::<NoParts>("props#beacon");
let mut panels = assets.texture("scene#beacon_panels");
assert_eq!(qualified.slots().len(), 2);
assert_eq!(panels.size(), UVec2::splat(16));
assert!(
unresolved(qualified).is_empty() && panels.take_unresolved().is_empty(),
"qualified, both resolve"
);
}
#[test]
fn a_name_only_one_source_uses_stays_bare() {
let assets = loaded(&["props.glb"]);
let bare = assets.mesh::<NoParts>("beacon");
let qualified = assets.mesh::<NoParts>("props#beacon");
assert_eq!(bare.slots().len(), 2);
assert_eq!(qualified.slots().len(), 2);
assert!(
unresolved(bare).is_empty() && unresolved(qualified).is_empty(),
"either way of pulling it works"
);
}
#[test]
fn a_sound_source_is_named_by_its_own_file_name() {
let assets = Assets::load([file("audio/theme.ogg", SWEEP)]).expect("the fixture decodes");
let theme = assets.sound("theme");
let as_a_mesh = assets.mesh::<NoParts>("theme");
assert_eq!(
theme.duration(),
Duration::from_secs(2),
"one source, one sound, named by the file it came in"
);
assert!(as_a_mesh.indices().is_empty());
assert_eq!(
unresolved(as_a_mesh)
.error()
.expect("pulling it as a mesh reaches nothing")
.to_string(),
"the game's assets did not resolve: no asset is named `theme`"
);
}
#[test]
fn a_texture_source_is_named_by_its_own_file_name() {
let assets = Assets::load([file("art/imp.png", IMP)]).expect("the fixture decodes");
let mut imp = assets.texture("imp");
assert_eq!(imp.size(), UVec2::splat(2), "one source, one texture");
assert_eq!(
imp.pixels()[..4],
[u8::MAX, 0, 0, u8::MAX],
"read row by row from the top left"
);
assert_eq!(assets.texture("imp#imp").size(), UVec2::splat(2));
assert!(imp.take_unresolved().is_empty());
let mut goblin = assets.texture("goblin");
assert_eq!(goblin.size(), UVec2::ZERO);
assert_eq!(
goblin
.take_unresolved()
.error()
.expect("only the second pull missed")
.to_string(),
"the game's assets did not resolve: no asset is named `goblin`"
);
}
#[cfg(feature = "ui")]
#[test]
fn a_font_source_is_named_by_its_own_file_name() {
let assets =
Assets::load([file("art/pixel-operator.ttf", OPERATOR)]).expect("the fixture decodes");
assert_eq!(
assets
.font("pixel-operator")
.expect("one source, one font, named by the file it came in")
.len(),
OPERATOR.len(),
"the bytes the source held"
);
assert_eq!(
assets
.font("nothing")
.expect_err("no source holds that name")
.to_string(),
"no asset is named `nothing`",
"a font that missed is the error it returned, and carries nothing"
);
let assets = Assets::load([
file("art/pixel-operator.ttf", OPERATOR),
file("art/OTHER.OTF", OPERATOR),
])
.expect("either extension, in either case");
assert!(assets.font("OTHER").is_ok());
}
#[test]
fn a_font_source_that_does_not_decode_cannot_start() {
let Err(error) = Assets::load([file("junk.ttf", b"not a font at all")]) else {
panic!("nothing decodes that");
};
assert!(
error.to_string().starts_with(
"the game's asset sources did not load: the asset source `junk.ttf` did not decode"
),
"got {error}"
);
}
#[test]
fn a_texture_source_that_does_not_decode_cannot_start() {
let Err(error) = Assets::load([file("junk.png", b"not a texture at all")]) else {
panic!("nothing decodes that");
};
assert!(
error.to_string().starts_with(
"the game's asset sources did not load: the asset source `junk.png` did not decode"
),
"got {error}"
);
}
#[test]
fn a_source_whose_file_name_holds_the_qualifier_cannot_start() {
let Err(error) = Assets::load([file("art/art#hello.glb", BEACON)]) else {
panic!("nothing could reach a name it shared");
};
assert_eq!(
error.to_string(),
"the game's asset sources did not load: the asset source `art#hello` holds a `#` in \
its file name, which is the mark a shared item name is read through, so rename it"
);
}
#[test]
fn two_sources_with_one_file_name_cannot_start() {
let Err(error) = Assets::load([
file("art/hello.glb", BEACON),
file("other/hello.glb", BEACON),
]) else {
panic!("nothing could tell the two apart");
};
assert_eq!(
error.to_string(),
"the game's asset sources did not load: two asset sources are both called `hello`, \
and a shared item name is reached by file name, so rename one of them"
);
}
#[test]
fn every_source_that_did_not_load_is_named_in_one_error() {
let Err(error) = Assets::load([
file("art/hello.glb", BEACON),
file("other/hello.glb", BEACON),
file("art#props.glb", BEACON),
]) else {
panic!("two file names are one, and the third holds the qualifier");
};
assert_eq!(
error.to_string(),
"the game's asset sources did not load: two asset sources are both called `hello`, \
and a shared item name is reached by file name, so rename one of them; the asset \
source `art#props` holds a `#` in its file name, which is the mark a shared item \
name is read through, so rename it",
"one error, in load order, naming both"
);
let Err(error) = Assets::load([
file("junk/beacon.glb", b"not a container at all"),
file("art/beacon.glb", BEACON),
]) else {
panic!("the first of these two does not decode");
};
let text = error.to_string();
assert!(
text.starts_with(
"the game's asset sources did not load: the asset source `junk/beacon.glb` did \
not decode"
),
"got {text}"
);
assert!(
!text.contains("both called"),
"and the file that did not load claims no file name: {text}"
);
}
}