use core::mem::discriminant;
use std::cell::RefCell;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::rc::Rc;
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::{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 {
loaded: Loaded,
unresolved: RefCell<BTreeSet<Unresolved>>,
}
impl Assets {
pub fn mesh<P: Part>(&self, name: &str) -> MeshData<P, NoClips> {
match self.loaded.find(name, Item::mesh) {
Ok(mesh) => self.built(mesh.resolved(name)),
Err(unaddressable) => {
self.record(unaddressable);
MeshData::empty()
}
}
}
pub fn model<P: Part, C: Clip>(&self, name: &str) -> MeshData<P, C> {
match self.loaded.find(name, Item::model) {
Ok(model) => self.built(model.resolved(name)),
Err(unaddressable) => {
self.record(unaddressable);
MeshData::empty()
}
}
}
fn built<P: Part, C: Clip>(
&self,
resolved: Result<MeshData<P, C>, Vec<Unresolved>>,
) -> MeshData<P, C> {
match resolved {
Ok(mesh) => mesh,
Err(unresolved) => {
unresolved.into_iter().for_each(|what| self.record(what));
MeshData::empty()
}
}
}
pub fn texture(&self, name: &str) -> TextureData {
match self.loaded.find(name, Item::texture) {
Ok(texture) => texture.clone(),
Err(unaddressable) => {
self.record(unaddressable);
TextureData::default()
}
}
}
pub fn relief(&self, name: &str) -> ReliefData {
ReliefData::loaded(self.texture(name))
}
pub fn skybox(&self, name: &str) -> SkyboxData {
let image = self.texture(name);
match image.drawn() {
true => SkyboxData::equirect(image),
false => SkyboxData::default(),
}
}
pub fn sound(&self, name: &str) -> SoundData {
match self.loaded.find(name, Item::sound) {
Ok(clip) => SoundData::loaded(Arc::clone(clip)),
Err(unaddressable) => {
self.record(unaddressable);
SoundData::empty()
}
}
}
#[cfg(feature = "ui")]
pub(crate) fn font(&self, name: &str) -> Result<Arc<[u8]>, Error> {
match self.loaded.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 = Loaded::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(Self {
loaded,
unresolved: RefCell::default(),
}),
false => Err(Error::msg(format!(
"the game's asset sources did not load: {}",
failures.join("; ")
))),
}
}
pub(crate) fn unresolved(&self) -> Option<Error> {
let unresolved = self.unresolved.take();
(!unresolved.is_empty()).then(|| {
let mut lines: Vec<String> = unresolved.iter().map(Unresolved::to_string).collect();
lines.sort();
Error::msg(format!(
"the game's assets did not resolve: {}",
lines.join("; ")
))
})
}
pub(crate) fn record(&self, what: Unresolved) {
let mut unresolved = self.unresolved.borrow_mut();
if !unresolved.contains(&what) {
log::debug!("{what}");
unresolved.insert(what);
}
}
}
#[derive(Default)]
struct Loaded {
items: HashMap<String, Vec<(Rc<str>, Item)>>,
stems: HashSet<Rc<str>>,
}
impl Loaded {
fn find<'a, T>(
&'a self,
name: &str,
of: impl Fn(&'a Item) -> Option<T>,
) -> Result<T, Unresolved> {
let held = |bare: &str| -> Vec<(&'a Rc<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(Unresolved::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(Unresolved::absent(name));
};
held(bare)
.into_iter()
.find(|(other, _)| other.as_ref() == stem)
.map(|(_, item)| item)
.ok_or_else(|| Unresolved::absent(name))
}
fn place(&mut self, file: DecodedFile) -> Result<(), Error> {
let DecodedFile { stem, items } = file;
if !self.stems.insert(Rc::clone(&stem)) {
return Err(shared_stem(&stem));
}
for (name, item) in items {
self.items
.entry(name)
.or_default()
.push((Rc::clone(&stem), item));
}
Ok(())
}
}
struct DecodedFile {
stem: Rc<str>,
items: Vec<(String, Item)>,
}
impl DecodedFile {
fn decode(source: &str, bytes: &[u8]) -> Result<Self, Error> {
let stem: Rc<str> = Rc::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;
mod texture;
mod unresolved;
#[cfg(test)]
mod tests {
use core::time::Duration;
use super::*;
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 a_miss_yields_empty_data_and_becomes_one_error() {
let assets = Assets::default();
assert_eq!(assets.mesh::<NoParts>("hull").slots().len(), 0);
assert_eq!(assets.texture("paint").size(), UVec2::ZERO);
assert_eq!(assets.mesh::<NoParts>("hull").slots().len(), 0);
let error = assets.unresolved().expect("the misses were recorded");
assert_eq!(
error.to_string(),
"the game's assets did not resolve: no asset is named `hull`; \
no asset is named `paint`"
);
assert!(assets.unresolved().is_none(), "the record is cleared");
}
#[test]
fn a_name_loaded_as_one_kind_is_a_miss_as_the_other() {
let assets = loaded(&["hello.glb"]);
assert!(assets.texture("beacon").pixels().is_empty());
assert!(assets.unresolved().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"]);
assert_eq!(
assets.mesh::<NoParts>("beacon").slots().len(),
0,
"bare, it reaches neither of them"
);
let error = assets.unresolved().expect("the ambiguity was recorded");
assert_eq!(
error.to_string(),
"the game's assets did not resolve: several sources call something `beacon`; \
ask for `props#beacon` or `scene#beacon`"
);
assert_eq!(assets.mesh::<NoParts>("props#beacon").slots().len(), 2);
assert_eq!(
assets.texture("scene#beacon_panels").size(),
UVec2::splat(16)
);
assert!(assets.unresolved().is_none(), "qualified, both resolve");
}
#[test]
fn a_name_only_one_source_uses_stays_bare() {
let assets = loaded(&["props.glb"]);
assert_eq!(assets.mesh::<NoParts>("beacon").slots().len(), 2);
assert_eq!(assets.mesh::<NoParts>("props#beacon").slots().len(), 2);
assert!(assets.unresolved().is_none(), "either way of asking 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");
assert_eq!(
assets.sound("theme").duration(),
Duration::from_secs(2),
"one source, one sound, named by the file it came in"
);
assert!(assets.mesh::<NoParts>("theme").indices().is_empty());
let error = assets.unresolved().expect("asking for it as a mesh missed");
assert_eq!(
error.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 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_eq!(assets.texture("goblin").size(), UVec2::ZERO);
let error = assets.unresolved().expect("only the second pull missed");
assert_eq!(
error.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`"
);
assert!(
assets.unresolved().is_none(),
"a font that missed is the error it returned, never a recorded miss"
);
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}"
);
}
}