use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
fn watch_dirs_of<'a>(paths: impl Iterator<Item = &'a str>) -> Vec<PathBuf> {
let mut dirs: BTreeSet<PathBuf> = BTreeSet::new();
for path in paths {
if let Some(parent) = Path::new(path).parent()
&& !parent.as_os_str().is_empty()
{
dirs.insert(parent.to_path_buf());
}
}
dirs.into_iter().collect()
}
#[derive(Debug, Clone)]
pub struct TextureSourceEntry {
pub source: String,
pub image_index: u32,
pub slot: usize,
}
#[derive(Debug, Clone)]
pub struct ColorLutSource {
pub resolved_path: String,
}
#[derive(Debug, Clone)]
pub struct MeshSourceEntry {
pub source: String,
pub primitive_index: u32,
pub lod_levels: u32,
pub lod_distances: Vec<f32>,
pub draw_indices: Vec<usize>,
}
#[derive(Debug, Clone, Default)]
pub struct MeshSourceMap {
pub entries: Vec<MeshSourceEntry>,
}
impl MeshSourceMap {
pub fn new() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn watch_dirs(&self) -> Vec<PathBuf> {
watch_dirs_of(self.entries.iter().map(|e| e.source.as_str()))
}
}
#[derive(Debug, Clone)]
pub struct ProceduralMeshSourceEntry {
pub name: String,
pub args: crate::components::ProceduralMesh,
pub draw_indices: Vec<usize>,
}
#[derive(Debug, Clone, Default)]
pub struct ProceduralMeshSourceMap {
pub entries: Vec<ProceduralMeshSourceEntry>,
}
impl ProceduralMeshSourceMap {
pub fn new() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
}
pub(crate) fn resolve_runtime_source_path(raw: &str) -> String {
concinnity_host::store::source::resolve_source_path(
raw,
concinnity_host::store::paths::assets_dir().as_deref(),
)
}
#[derive(Debug, Clone)]
pub struct ShaderStageSourceEntry {
pub kind: crate::components::shader::ShaderKind,
pub resolved_path: String,
}
#[derive(Debug, Clone, Default)]
pub struct ShaderStageSourceMap {
pub entries: Vec<ShaderStageSourceEntry>,
}
impl ShaderStageSourceMap {
pub fn new() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn watch_dirs(&self) -> Vec<PathBuf> {
watch_dirs_of(self.entries.iter().map(|e| e.resolved_path.as_str()))
}
}
#[derive(Debug, Clone)]
pub struct SkinnedMeshSourceEntry {
pub source: String,
pub skin_index: u32,
pub skinned_index: usize,
pub vertex_base: u32,
pub vertex_count: usize,
pub index_count: usize,
pub joint_count: usize,
}
#[derive(Debug, Clone, Default)]
pub struct SkinnedMeshSourceMap {
pub entries: Vec<SkinnedMeshSourceEntry>,
}
impl SkinnedMeshSourceMap {
pub fn new() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn watch_dirs(&self) -> Vec<PathBuf> {
watch_dirs_of(self.entries.iter().map(|e| e.source.as_str()))
}
}
#[derive(Debug, Clone)]
pub struct EnvironmentMapSource {
pub resolved_path: String,
pub prefilter_face_size: u32,
pub irradiance_face_size: u32,
pub prefilter_samples: u32,
pub prefilter_clamp: f32,
}
#[derive(Debug, Clone, Default)]
pub struct TextureSourceMap {
pub entries: Vec<TextureSourceEntry>,
}
impl TextureSourceMap {
pub fn new() -> Self {
Self::default()
}
pub fn push_texture(&mut self, source: String, image_index: u32, slot: usize) {
self.entries.push(TextureSourceEntry {
source,
image_index,
slot,
});
}
pub fn watch_dirs(&self) -> Vec<PathBuf> {
watch_dirs_of(self.entries.iter().map(|e| e.source.as_str()))
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
}
#[derive(Default)]
pub struct HotReloadSources {
pub map: TextureSourceMap,
pub color_lut: Option<ColorLutSource>,
pub environment_map: Option<EnvironmentMapSource>,
pub meshes: MeshSourceMap,
pub skinned_meshes: SkinnedMeshSourceMap,
pub procedural_meshes: ProceduralMeshSourceMap,
pub shader_stages: ShaderStageSourceMap,
pub world_jsonl_path: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_keeps_paths_with_a_directory_component() {
assert_eq!(
resolve_runtime_source_path("shaders/x.metal"),
"shaders/x.metal"
);
}
fn mesh_entry(source: &str) -> MeshSourceEntry {
MeshSourceEntry {
source: source.to_string(),
primitive_index: 0,
lod_levels: 1,
lod_distances: Vec::new(),
draw_indices: vec![0],
}
}
fn skinned_entry(source: &str) -> SkinnedMeshSourceEntry {
SkinnedMeshSourceEntry {
source: source.to_string(),
skin_index: 0,
skinned_index: 0,
vertex_base: 0,
vertex_count: 3,
index_count: 3,
joint_count: 1,
}
}
fn dirs(paths: &[PathBuf]) -> Vec<String> {
paths.iter().map(|p| p.display().to_string()).collect()
}
#[test]
fn texture_map_starts_empty_and_counts_pushed_entries() {
let mut map = TextureSourceMap::new();
assert!(map.is_empty());
assert_eq!(map.len(), 0);
map.push_texture("assets/wall.png".to_string(), 0, 4);
map.push_texture("assets/scene.glb".to_string(), 2, 7);
assert!(!map.is_empty());
assert_eq!(map.len(), 2);
assert_eq!(map.entries[0].slot, 4);
assert_eq!(map.entries[0].image_index, 0);
assert_eq!(map.entries[1].slot, 7);
assert_eq!(map.entries[1].image_index, 2);
}
#[test]
fn texture_watch_dirs_dedups_shared_parents() {
let mut map = TextureSourceMap::new();
map.push_texture("assets/textures/wall.png".to_string(), 0, 0);
map.push_texture("assets/textures/floor.png".to_string(), 0, 1);
map.push_texture("assets/models/scene.glb".to_string(), 0, 2);
assert_eq!(
dirs(&map.watch_dirs()),
["assets/models", "assets/textures"],
"one entry per unique parent, sorted"
);
}
#[test]
fn texture_watch_dirs_skips_bare_filenames() {
let mut map = TextureSourceMap::new();
map.push_texture("wall.png".to_string(), 0, 0);
assert!(map.watch_dirs().is_empty());
map.push_texture("assets/floor.png".to_string(), 0, 1);
assert_eq!(dirs(&map.watch_dirs()), ["assets"]);
}
#[test]
fn mesh_map_watches_parents_and_keeps_every_draw_slot() {
let mut map = MeshSourceMap::new();
assert!(map.is_empty());
let mut shared = mesh_entry("assets/models/prop.glb");
shared.draw_indices = vec![3, 9, 12];
map.entries.push(shared);
map.entries.push(mesh_entry("assets/models/tree.glb"));
map.entries.push(mesh_entry("bare.glb"));
assert_eq!(map.len(), 3);
assert_eq!(
dirs(&map.watch_dirs()),
["assets/models"],
"the two rooted entries share one dir, the bare one is skipped"
);
assert_eq!(
map.entries[0].draw_indices,
vec![3, 9, 12],
"a reload has to rewrite every slot carrying this mesh"
);
}
#[test]
fn skinned_mesh_map_watches_parents() {
let mut map = SkinnedMeshSourceMap::new();
assert!(map.is_empty());
map.entries.push(skinned_entry("assets/chars/fox.glb"));
map.entries.push(skinned_entry("assets/chars/wolf.glb"));
map.entries.push(skinned_entry("fox.glb"));
assert_eq!(map.len(), 3);
assert_eq!(dirs(&map.watch_dirs()), ["assets/chars"]);
}
#[test]
fn shader_stage_map_watches_resolved_parents() {
use crate::components::shader::ShaderKind;
let mut map = ShaderStageSourceMap::new();
assert!(map.is_empty());
for (kind, path) in [
(ShaderKind::Vertex, "shaders/scene.metal"),
(ShaderKind::Fragment, "shaders/scene.metal"),
(ShaderKind::VertexInstanced, "shaders/instanced/pass.metal"),
] {
map.entries.push(ShaderStageSourceEntry {
kind,
resolved_path: path.to_string(),
});
}
assert_eq!(map.len(), 3);
assert_eq!(
dirs(&map.watch_dirs()),
["shaders", "shaders/instanced"],
"the two stages sharing a file collapse to one dir"
);
}
#[test]
fn shader_stage_map_skips_an_empty_resolved_path() {
let mut map = ShaderStageSourceMap::new();
map.entries.push(ShaderStageSourceEntry {
kind: crate::components::shader::ShaderKind::Fragment,
resolved_path: String::new(),
});
assert!(map.watch_dirs().is_empty());
}
#[test]
fn procedural_mesh_map_counts_entries() {
let mut map = ProceduralMeshSourceMap::new();
assert!(map.is_empty());
assert_eq!(map.len(), 0);
map.entries.push(ProceduralMeshSourceEntry {
name: "ground".to_string(),
args: Default::default(),
draw_indices: vec![0, 1],
});
assert!(!map.is_empty());
assert_eq!(map.len(), 1);
}
#[test]
fn bundle_defaults_to_nothing_captured() {
let sources = HotReloadSources::default();
assert!(sources.map.is_empty());
assert!(sources.meshes.is_empty());
assert!(sources.skinned_meshes.is_empty());
assert!(sources.procedural_meshes.is_empty());
assert!(sources.shader_stages.is_empty());
assert!(sources.color_lut.is_none());
assert!(sources.environment_map.is_none());
assert!(sources.world_jsonl_path.is_none());
}
}