use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::{Path, PathBuf},
};
use crate::{
bim::supports_builtin_decode,
cache::files_are_identical,
error::{DoomError, Result},
manifest::RequiredEditableManifest,
sets::{find_set_tokens, normalize_set_name, replace_set_tokens},
};
const SUPPORTED_EXPORT_EXTENSIONS: &[&str] = &[".png", ".tif", ".tiff", ".dds"];
#[derive(Default)]
struct ExportIndex {
tree_exact: BTreeMap<String, PathBuf>,
tree_by_stem: BTreeMap<String, Vec<PathBuf>>,
model_by_stem: BTreeMap<String, Vec<PathBuf>>,
samuel_file_count: u64,
model_file_count: u64,
}
pub fn import_samuel_exports(
samuel_export_root: Option<&Path>,
model_export_root: Option<&Path>,
manifest_path: &Path,
source_set: Option<&str>,
dry_run: bool,
) -> Result<()> {
if samuel_export_root.is_none() && model_export_root.is_none() {
return Err(DoomError::message(
"Could not resolve a texture export root. Pass --samuel-export-root, --model-export-root, or place fresh exports under Downloads/exports and Downloads/modelExports.",
));
}
if let Some(samuel_export_root) = samuel_export_root {
if !samuel_export_root.exists() {
return Err(DoomError::message(format!(
"Missing SAMUEL export root: {}",
samuel_export_root.display()
)));
}
}
if let Some(model_export_root) = model_export_root {
if !model_export_root.exists() {
return Err(DoomError::message(format!(
"Missing model export root: {}",
model_export_root.display()
)));
}
}
let manifest = load_manifest(manifest_path)?;
let export_index = build_export_index(samuel_export_root, model_export_root)?;
if export_index.samuel_file_count > 0 || export_index.model_file_count > 0 {
println!(
"Indexed {} SAMUEL export file(s) and {} model export file(s).",
export_index.samuel_file_count, export_index.model_file_count
);
}
let mut copied = 0_u64;
let mut reused = 0_u64;
let mut skipped_decodeable = 0_u64;
let mut failures = Vec::new();
for export in &manifest.exports {
if should_skip_external_import(export) {
println!(
"Skipping {} because the source pack can decode it directly from {}",
export.target, export.source_texture
);
skipped_decodeable += 1;
continue;
}
let relative_texture = PathBuf::from(&export.relative_texture);
let destination_file = PathBuf::from(&export.editable_texture);
match find_texture_export(
&export_index,
samuel_export_root,
model_export_root,
&relative_texture,
&destination_file,
source_set,
) {
Ok(source_file) => {
if dry_run {
println!(
"[dry-run] copy {} -> {}",
source_file.display(),
destination_file.display()
);
} else {
if let Some(parent) = destination_file.parent() {
fs::create_dir_all(parent)?;
}
if copy_export_if_needed(&source_file, &destination_file)? {
println!(
"Copied {} -> {}",
source_file.display(),
destination_file.display()
);
copied += 1;
} else {
println!(
"Reused existing editable export {}",
destination_file.display()
);
reused += 1;
}
}
if dry_run {
copied += 1;
}
}
Err(error) => failures.push(error.to_string()),
}
}
if !failures.is_empty() {
let details = failures
.into_iter()
.map(|failure| format!("- {failure}"))
.collect::<Vec<_>>()
.join("\n");
let hints = build_failure_hints(manifest_path, &manifest, source_set)
.into_iter()
.map(|hint| format!("- {hint}"))
.collect::<Vec<_>>()
.join("\n");
return Err(DoomError::message(format!(
"Could not import {} export(s):\n{}\nHints:\n{}",
details.lines().count(),
details,
hints
)));
}
println!("Imported {copied} texture export(s).");
if reused > 0 {
println!("Reused {reused} existing editable texture export(s).");
}
if skipped_decodeable > 0 {
println!(
"Skipped {skipped_decodeable} decodeable texture export(s); craft will derive those from the source pack."
);
}
Ok(())
}
pub fn load_manifest(path: &Path) -> Result<RequiredEditableManifest> {
if !path.exists() {
return Err(DoomError::message(format!(
"Missing required editable manifest: {}",
path.display()
)));
}
let manifest = serde_json::from_str::<RequiredEditableManifest>(&fs::read_to_string(path)?)?;
if manifest.exports.is_empty() {
return Err(DoomError::message(format!(
"Manifest does not contain an exports list: {}",
path.display()
)));
}
Ok(manifest)
}
fn build_failure_hints(
manifest_path: &Path,
manifest: &RequiredEditableManifest,
source_set: Option<&str>,
) -> Vec<String> {
let mut hints = vec![format!("Manifest: {}", manifest_path.display())];
let manifest_source_set = normalize_set_name(&manifest.source_set);
let manifest_target_set = normalize_set_name(&manifest.target_set);
let export_sets = infer_manifest_sets(manifest);
if let Some(target_set) = manifest_target_set {
hints.push(format!("Manifest target set: {target_set}"));
} else if !export_sets.is_empty() {
hints.push(format!(
"Manifest texture paths use: {}",
export_sets.join(", ")
));
}
if manifest_source_set.is_some()
&& manifest_source_set != normalize_set_name(&manifest.target_set)
{
hints.push(format!("Manifest source pack set: {}", manifest.source_set));
}
if let Some(source_set) = source_set {
hints.push(format!("Current --source-set remap: {source_set}"));
} else {
hints
.push("If SAMUEL exported a different set, rerun with --source-set <set>.".to_string());
hints.push(
"If this manifest is stale, regenerate it with craft --target-set <set> --editable-root <fresh-root> and pass that manifest here."
.to_string(),
);
}
hints.push(
"Downloads/modelExports is also supported now when SAMUEL exported texture images next to .obj/.mtl output instead of the warehouse-style exports tree."
.to_string(),
);
hints
}
fn infer_manifest_sets(manifest: &RequiredEditableManifest) -> Vec<String> {
let mut sets = BTreeSet::new();
for export in &manifest.exports {
for set_name in find_set_tokens(&export.relative_texture) {
sets.insert(set_name);
}
}
sets.into_iter().collect()
}
fn should_skip_external_import(export: &crate::manifest::RequiredExport) -> bool {
let source_texture = Path::new(&export.source_texture);
source_texture.is_file() && supports_builtin_decode(source_texture)
}
fn candidate_roots(samuel_export_root: &Path) -> Vec<PathBuf> {
let mut roots = vec![samuel_export_root.to_path_buf()];
let nested_exports = samuel_export_root.join("exports");
if nested_exports.is_dir() {
roots.push(nested_exports);
}
roots
}
fn search_relative_textures(relative_texture: &Path, source_set: Option<&str>) -> Vec<PathBuf> {
let remapped = remap_relative_texture_set(relative_texture, source_set);
if remapped == relative_texture {
vec![remapped]
} else {
vec![remapped, relative_texture.to_path_buf()]
}
}
fn remap_relative_texture_set(relative_texture: &Path, source_set: Option<&str>) -> PathBuf {
let Some(source_set) = source_set.and_then(normalize_set_name) else {
return relative_texture.to_path_buf();
};
relative_texture
.components()
.map(|component| replace_set_tokens(component.as_os_str().to_string_lossy(), &source_set))
.collect::<PathBuf>()
}
fn find_texture_export(
export_index: &ExportIndex,
samuel_export_root: Option<&Path>,
model_export_root: Option<&Path>,
relative_texture: &Path,
destination_file: &Path,
source_set: Option<&str>,
) -> Result<PathBuf> {
let expected_extension = destination_file
.extension()
.map(|value| format!(".{}", value.to_string_lossy().to_ascii_lowercase()))
.filter(|extension| SUPPORTED_EXPORT_EXTENSIONS.contains(&extension.as_str()))
.unwrap_or_else(|| ".png".to_string());
let relative_candidates = search_relative_textures(relative_texture, source_set);
if samuel_export_root.is_some() {
if let Some(candidate) =
find_tree_export(export_index, &relative_candidates, &expected_extension)?
{
return Ok(candidate);
}
}
if model_export_root.is_some() {
if let Some(candidate) =
find_model_export(export_index, &relative_candidates, &expected_extension)?
{
return Ok(candidate);
}
}
let roots = [
samuel_export_root.map(|path| format!("exports={}", path.display())),
model_export_root.map(|path| format!("modelExports={}", path.display())),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(", ");
let tried = relative_candidates
.iter()
.map(|texture| texture.to_string_lossy().to_string())
.collect::<Vec<_>>()
.join(", ");
Err(DoomError::message(format!(
"Could not find a texture export for any of these paths under {roots}: {tried}",
)))
}
fn find_tree_export(
export_index: &ExportIndex,
relative_candidates: &[PathBuf],
expected_extension: &str,
) -> Result<Option<PathBuf>> {
for search_texture in relative_candidates {
for expected_relative in exact_relative_keys(search_texture, expected_extension) {
if let Some(candidate) = export_index.tree_exact.get(&expected_relative) {
return Ok(Some(candidate.clone()));
}
}
}
let mut recursive_candidates = BTreeSet::new();
for search_texture in relative_candidates {
let texture_stem = search_texture
.file_stem()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
let Some(candidates) = export_index.tree_by_stem.get(&texture_stem) else {
continue;
};
for candidate in candidates {
let name = candidate
.file_name()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
if name.starts_with(&texture_stem)
&& (name.ends_with(expected_extension) || name.ends_with(".png"))
{
recursive_candidates.insert(candidate.clone());
}
}
}
if recursive_candidates.is_empty() {
return Ok(None);
}
let candidates = recursive_candidates.into_iter().collect::<Vec<_>>();
let preferred_base_exports = candidates
.iter()
.filter(|candidate| {
relative_candidates
.iter()
.any(|texture| is_exact_base_export(candidate, texture, expected_extension))
})
.cloned()
.collect::<Vec<_>>();
if preferred_base_exports.len() == 1 {
return Ok(Some(preferred_base_exports[0].clone()));
}
let preferred_albedo_exports = candidates
.iter()
.filter(|candidate| {
relative_candidates
.iter()
.any(|texture| is_exact_albedo_export(candidate, texture, expected_extension))
})
.cloned()
.collect::<Vec<_>>();
if preferred_albedo_exports.len() == 1 {
return Ok(Some(preferred_albedo_exports[0].clone()));
}
let mut ranked = candidates
.into_iter()
.map(|candidate| {
let score = relative_candidates
.iter()
.map(|search_texture| {
score_candidate(&candidate, search_texture, expected_extension)
+ export_variant_score(&candidate, search_texture, expected_extension)
})
.max()
.unwrap_or_default();
(score, candidate)
})
.collect::<Vec<_>>();
ranked.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
let best_score = ranked.first().map(|item| item.0).unwrap_or_default();
let best_candidates = ranked
.into_iter()
.filter(|item| item.0 == best_score)
.map(|item| item.1)
.collect::<Vec<_>>();
if best_candidates.len() > 1 {
let preview = best_candidates
.iter()
.take(8)
.map(|candidate| format!("- {}", candidate.display()))
.collect::<Vec<_>>()
.join("\n");
return Err(DoomError::message(format!(
"Ambiguous SAMUEL exports for {}:\n{preview}",
relative_candidates
.first()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "<unknown>".to_string())
)));
}
Ok(best_candidates.into_iter().next())
}
fn find_model_export(
export_index: &ExportIndex,
relative_candidates: &[PathBuf],
expected_extension: &str,
) -> Result<Option<PathBuf>> {
let mut matches = Vec::new();
let mut seen = BTreeSet::new();
for relative_texture in relative_candidates {
for stem in model_export_stems(relative_texture) {
let normalized_stem = stem.to_ascii_lowercase();
let Some(candidates) = export_index.model_by_stem.get(&normalized_stem) else {
continue;
};
for candidate in candidates {
if !seen.insert(candidate.clone()) {
continue;
}
let score = relative_candidates
.iter()
.map(|candidate_relative_texture| {
score_model_export(
candidate,
candidate_relative_texture,
expected_extension,
)
})
.max()
.unwrap_or(i32::MIN);
if score > i32::MIN / 2 {
matches.push((score, candidate.clone()));
}
}
}
}
if matches.is_empty() {
return Ok(None);
}
matches.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
let best_score = matches.first().map(|item| item.0).unwrap_or_default();
let best_candidates = matches
.into_iter()
.filter(|item| item.0 == best_score)
.map(|item| item.1)
.collect::<Vec<_>>();
if best_candidates.len() > 1 {
let preview = best_candidates
.iter()
.take(8)
.map(|candidate| format!("- {}", candidate.display()))
.collect::<Vec<_>>()
.join("\n");
return Err(DoomError::message(format!(
"Ambiguous modelExports match:\n{preview}"
)));
}
Ok(best_candidates.into_iter().next())
}
fn build_export_index(
samuel_export_root: Option<&Path>,
model_export_root: Option<&Path>,
) -> Result<ExportIndex> {
let mut index = ExportIndex::default();
if let Some(samuel_export_root) = samuel_export_root {
for root in candidate_roots(samuel_export_root) {
if !root.is_dir() {
continue;
}
for entry in walkdir::WalkDir::new(&root)
.into_iter()
.filter_map(|entry| entry.ok())
{
if !entry.file_type().is_file() {
continue;
}
let candidate = entry.path().to_path_buf();
if !is_supported_export_file(&candidate) {
continue;
}
index.samuel_file_count += 1;
if let Ok(relative) = candidate.strip_prefix(&root) {
for key in tree_relative_keys(relative) {
index
.tree_exact
.entry(key)
.or_insert_with(|| candidate.clone());
}
}
let stem_key = export_tree_stem_key(&candidate);
if !stem_key.is_empty() {
index
.tree_by_stem
.entry(stem_key)
.or_default()
.push(candidate);
}
}
}
}
if let Some(model_export_root) = model_export_root {
for entry in walkdir::WalkDir::new(model_export_root)
.into_iter()
.filter_map(|entry| entry.ok())
{
if !entry.file_type().is_file() {
continue;
}
let candidate = entry.path().to_path_buf();
if !is_supported_export_file(&candidate) {
continue;
}
index.model_file_count += 1;
let stem = candidate
.file_stem()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
if !stem.is_empty() {
index.model_by_stem.entry(stem).or_default().push(candidate);
}
}
}
Ok(index)
}
fn copy_export_if_needed(source_file: &Path, destination_file: &Path) -> Result<bool> {
if destination_file.is_file() && files_are_identical(source_file, destination_file)? {
return Ok(false);
}
fs::copy(source_file, destination_file)?;
Ok(true)
}
fn exact_relative_keys(search_texture: &Path, expected_extension: &str) -> Vec<String> {
let expected_relative =
search_texture.with_extension(expected_extension.trim_start_matches('.'));
let mut keys = vec![normalize_relative_key(&expected_relative)];
keys.push(normalize_relative_key(
&PathBuf::from("warehouse").join(expected_relative),
));
keys
}
fn tree_relative_keys(relative_path: &Path) -> Vec<String> {
let mut keys = vec![normalize_relative_key(relative_path)];
let warehouse_prefix = Path::new("warehouse");
if let Ok(stripped) = relative_path.strip_prefix(warehouse_prefix) {
keys.push(normalize_relative_key(stripped));
}
keys
}
fn normalize_relative_key(path: &Path) -> String {
path.components()
.map(|component| component.as_os_str().to_string_lossy().to_ascii_lowercase())
.collect::<Vec<_>>()
.join("/")
}
fn export_tree_stem_key(candidate: &Path) -> String {
let file_name = candidate
.file_name()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
let base_name = file_name.split('$').next().unwrap_or(&file_name);
Path::new(base_name)
.file_stem()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default()
}
fn is_supported_export_file(path: &Path) -> bool {
path.extension()
.map(|value| format!(".{}", value.to_string_lossy().to_ascii_lowercase()))
.map(|extension| SUPPORTED_EXPORT_EXTENSIONS.contains(&extension.as_str()))
.unwrap_or(false)
}
fn score_model_export(candidate: &Path, relative_texture: &Path, expected_extension: &str) -> i32 {
let candidate_extension = candidate
.extension()
.map(|value| format!(".{}", value.to_string_lossy().to_ascii_lowercase()))
.unwrap_or_default();
if candidate_extension != expected_extension && candidate_extension != ".png" {
return i32::MIN;
}
let candidate_stem = candidate
.file_stem()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
let candidate_path = candidate.to_string_lossy().to_ascii_lowercase();
let mut best_score = i32::MIN;
for stem in model_export_stems(relative_texture) {
let normalized_stem = stem.to_ascii_lowercase();
if candidate_stem != normalized_stem {
continue;
}
let mut score = 200;
if is_first_person_texture(relative_texture) {
if candidate_path.contains("doomslayer_cine\\images")
|| candidate_path.contains("doomslayer_cine/images")
{
score += 100;
}
if candidate_path.contains("doomslayer_cine_3p") {
score -= 100;
}
} else {
if candidate_path.contains("doomslayer_cine_3p\\images")
|| candidate_path.contains("doomslayer_cine_3p/images")
{
score += 100;
}
if candidate_path.contains("doomslayer_cine\\images")
|| candidate_path.contains("doomslayer_cine/images")
{
score -= 20;
}
}
if candidate_extension == expected_extension {
score += 20;
}
best_score = best_score.max(score);
}
best_score
}
fn model_export_stems(relative_texture: &Path) -> Vec<String> {
let stem = relative_texture
.file_stem()
.map(|value| value.to_string_lossy().to_string())
.unwrap_or_default();
let without_set = trim_set_suffix(&stem);
match without_set.as_str() {
"doomslayer_1p_left" => vec!["doomslayer_arm_left".to_string()],
"doomslayer_1p_right" => vec!["doomslayer_arm_right".to_string()],
_ => vec![without_set],
}
}
fn trim_set_suffix(stem: &str) -> String {
if let Some((prefix, suffix)) = stem.rsplit_once("_set") {
if suffix.chars().all(|ch| ch.is_ascii_digit()) {
return prefix.to_string();
}
}
stem.to_string()
}
fn is_first_person_texture(relative_texture: &Path) -> bool {
relative_texture
.to_string_lossy()
.to_ascii_lowercase()
.contains("doomslayer_1p")
}
fn score_candidate(candidate: &Path, relative_texture: &Path, expected_extension: &str) -> i32 {
let expected_path = relative_texture.with_extension(expected_extension.trim_start_matches('.'));
let mut score = 0;
let candidate_name = candidate
.file_name()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
let expected_name = expected_path
.file_name()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
let candidate_stem = candidate
.file_stem()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
let expected_stem = expected_path
.file_stem()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
if candidate_name == expected_name {
score += 30;
} else if candidate_stem.starts_with(&expected_stem) {
score += 10;
}
if path_has_suffix(
candidate.parent().unwrap_or(candidate),
expected_path.parent().unwrap_or(&expected_path),
) {
score += 60;
} else if path_has_suffix(
candidate.parent().unwrap_or(candidate),
&PathBuf::from("warehouse").join(expected_path.parent().unwrap_or(&expected_path)),
) {
score += 50;
}
if candidate
.extension()
.map(|value| format!(".{}", value.to_string_lossy().to_ascii_lowercase()))
.as_deref()
== Some(expected_extension)
{
score += 5;
}
score
}
fn path_has_suffix(path: &Path, suffix: &Path) -> bool {
let path_parts = path
.components()
.map(|component| component.as_os_str().to_string_lossy().to_ascii_lowercase())
.collect::<Vec<_>>();
let suffix_parts = suffix
.components()
.map(|component| component.as_os_str().to_string_lossy().to_ascii_lowercase())
.collect::<Vec<_>>();
if suffix_parts.len() > path_parts.len() {
return false;
}
path_parts[path_parts.len() - suffix_parts.len()..] == suffix_parts
}
fn is_exact_base_export(
candidate: &Path,
relative_texture: &Path,
expected_extension: &str,
) -> bool {
let candidate_name = candidate
.file_name()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
let texture_name = relative_texture
.file_name()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
candidate_name == format!("{texture_name}{expected_extension}")
}
fn is_exact_albedo_export(
candidate: &Path,
relative_texture: &Path,
expected_extension: &str,
) -> bool {
let candidate_name = candidate
.file_name()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
let texture_name = relative_texture
.file_name()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
candidate_name == format!("{texture_name}$streamed$mtlkind=albedo{expected_extension}")
}
fn export_variant_score(
candidate: &Path,
relative_texture: &Path,
expected_extension: &str,
) -> i32 {
let candidate_name = candidate
.file_name()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
let candidate_stem = candidate
.file_stem()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
let texture_name = relative_texture
.file_name()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
let texture_stem = relative_texture
.file_stem()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
let mut score = 0;
if is_exact_base_export(candidate, relative_texture, expected_extension) {
score += 500;
}
if is_exact_albedo_export(candidate, relative_texture, expected_extension) {
score += 400;
}
if candidate_stem.contains("$mtlkind=albedo") {
score += 120;
}
for material in [
"$mtlkind=normal",
"$mtlkind=specular",
"$mtlkind=smoothness",
"$mtlkind=sssmask",
"$mtlkind=bloommask",
"$mtlkind=blendmask",
] {
if candidate_stem.contains(material) {
score -= 150;
}
}
if candidate_name.starts_with(&format!("{texture_name}$")) {
score += 100;
}
if candidate_stem.starts_with(&format!("{texture_stem}_hq"))
|| candidate_name.contains(&format!("{texture_stem}_hq."))
{
score -= 80;
}
for suffix in ["_n", "_s", "_g", "_e", "_sss", "_mask"] {
if candidate_stem.starts_with(&format!("{texture_stem}{suffix}")) {
score -= 100;
}
}
score
}
#[cfg(test)]
mod tests {
use std::{
fs,
path::{Path, PathBuf},
};
use tempfile::TempDir;
use crate::manifest::{RequiredEditableManifest, RequiredExport};
use super::{import_samuel_exports, load_manifest};
#[test]
fn repo_sample_set16_exports_cover_current_manifest() {
let temp_dir = TempDir::new().expect("tempdir");
let repo_root = repo_root();
let sample_root = repo_root.join("build/test/samuel-set16-exports");
let template_manifest =
repo_root.join("build/rtx-editable-source-set16/required-editable-textures.json");
let manifest_path =
write_temp_manifest(&template_manifest, temp_dir.path()).expect("temp manifest");
import_samuel_exports(
Some(&sample_root),
None,
&manifest_path,
Some("set16"),
true,
)
.expect("set16 fixture should satisfy the current manifest");
}
#[test]
fn repo_sample_set52_exports_cover_current_manifest() {
let temp_dir = TempDir::new().expect("tempdir");
let repo_root = repo_root();
let sample_root = repo_root.join("build/test/samuel-exports");
let template_manifest =
repo_root.join("build/rtx-editable-source-set16/required-editable-textures.json");
let manifest_path =
write_temp_manifest(&template_manifest, temp_dir.path()).expect("temp manifest");
import_samuel_exports(
Some(&sample_root),
None,
&manifest_path,
Some("set52"),
true,
)
.expect("set52 fixture should satisfy the current manifest");
}
#[test]
fn model_exports_cover_current_manifest_when_tree_export_is_missing() {
let temp_dir = TempDir::new().expect("tempdir");
let repo_root = repo_root();
let template_manifest =
repo_root.join("build/rtx-editable-source-set16/required-editable-textures.json");
let manifest_path =
write_temp_manifest(&template_manifest, temp_dir.path()).expect("temp manifest");
let model_root = temp_dir.path().join("modelExports");
let model_file = model_root
.join("doomslayer_cine_3p")
.join("images")
.join("doomslayer_game_helmet.png");
fs::create_dir_all(model_file.parent().expect("parent")).expect("model parent");
fs::write(&model_file, b"png").expect("model file");
import_samuel_exports(None, Some(&model_root), &manifest_path, None, true)
.expect("modelExports fallback should satisfy the current manifest");
}
#[test]
fn decodeable_source_bim_skips_model_export_fallback() {
let temp_dir = TempDir::new().expect("tempdir");
let repo_root = repo_root();
let source_bim = repo_root
.join("rtx-slayer")
.join("warehouse")
.join("models")
.join("customization")
.join("characters")
.join("doomslayer")
.join("set52")
.join("doomslayer_game_helmet_set52.tga");
assert!(source_bim.is_file(), "expected repo helmet source BIM");
let model_root = temp_dir.path().join("modelExports");
let model_file = model_root
.join("doomslayer_cine_3p")
.join("images")
.join("doomslayer_game_helmet.png");
fs::create_dir_all(model_file.parent().expect("parent")).expect("model parent");
fs::write(&model_file, b"png").expect("model file");
let editable_file = temp_dir.path().join("editable").join("helmet.png");
let manifest = RequiredEditableManifest {
message: "test".to_string(),
source_set: "set52".to_string(),
target_set: "set16".to_string(),
exports: vec![RequiredExport {
target: "third-person-helmet".to_string(),
relative_texture:
"models/customization/characters/doomslayer/set16/doomslayer_game_helmet_set16.tga"
.to_string(),
source_texture: source_bim.display().to_string(),
editable_texture: editable_file.display().to_string(),
}],
};
let manifest_path = temp_dir.path().join("required-editable-textures.json");
fs::write(
&manifest_path,
serde_json::to_string_pretty(&manifest).expect("manifest json"),
)
.expect("write manifest");
import_samuel_exports(None, Some(&model_root), &manifest_path, None, false)
.expect("decodeable source BIM should be skipped");
assert!(
!editable_file.exists(),
"decodeable source BIM should not import a modelExports override"
);
}
fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|path| path.parent())
.expect("workspace root")
.to_path_buf()
}
fn write_temp_manifest(
template_manifest: &Path,
temp_root: &Path,
) -> crate::error::Result<PathBuf> {
let template = load_manifest(template_manifest)?;
let rewritten = RequiredEditableManifest {
message: template.message,
source_set: template.source_set,
target_set: template.target_set,
exports: template
.exports
.into_iter()
.enumerate()
.map(|(index, export)| RequiredExport {
target: export.target,
relative_texture: export.relative_texture,
source_texture: export.source_texture,
editable_texture: temp_root
.join(format!("editable-{index}.png"))
.display()
.to_string(),
})
.collect(),
};
let manifest_path = temp_root.join("required-editable-textures.json");
fs::write(&manifest_path, serde_json::to_string_pretty(&rewritten)?)?;
Ok(manifest_path)
}
}