use std::{
collections::BTreeSet,
fs,
path::{Path, PathBuf},
};
use crate::{
error::{DoomError, Result},
manifest::RequiredEditableManifest,
sets::{find_set_tokens, normalize_set_name, replace_set_tokens},
};
const SUPPORTED_EXPORT_EXTENSIONS: &[&str] = &[".png", ".tif", ".tiff", ".dds"];
pub fn import_samuel_exports(
samuel_export_root: &Path,
manifest_path: &Path,
source_set: Option<&str>,
dry_run: bool,
) -> Result<()> {
if !samuel_export_root.exists() {
return Err(DoomError::message(format!(
"Missing SAMUEL export root: {}",
samuel_export_root.display()
)));
}
let manifest = load_manifest(manifest_path)?;
let mut copied = 0_u64;
let mut failures = Vec::new();
for export in &manifest.exports {
let relative_texture = PathBuf::from(&export.relative_texture);
let destination_file = PathBuf::from(&export.editable_texture);
match find_samuel_export(samuel_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)?;
}
fs::copy(&source_file, &destination_file)?;
println!("Copied {} -> {}", source_file.display(), destination_file.display());
}
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} SAMUEL export(s).");
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
}
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 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_samuel_export(
samuel_export_root: &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);
let mut exact_candidates = Vec::new();
for search_texture in &relative_candidates {
let expected_relative = search_texture.with_extension(expected_extension.trim_start_matches('.'));
for root in candidate_roots(samuel_export_root) {
exact_candidates.push(root.join(&expected_relative));
exact_candidates.push(root.join("warehouse").join(&expected_relative));
}
}
if let Some(candidate) = exact_candidates.into_iter().find(|candidate| candidate.is_file()) {
return Ok(candidate);
}
let mut recursive_candidates = BTreeSet::new();
for root in candidate_roots(samuel_export_root) {
for search_texture in &relative_candidates {
let texture_stem = search_texture
.file_stem()
.map(|value| value.to_string_lossy().to_string())
.unwrap_or_default();
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();
let name = candidate
.file_name()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
if name.starts_with(&texture_stem.to_ascii_lowercase())
&& (name.ends_with(&expected_extension) || name.ends_with(".png"))
{
recursive_candidates.insert(candidate);
}
}
}
}
if recursive_candidates.is_empty() {
let tried = relative_candidates
.iter()
.map(|texture| texture.to_string_lossy().to_string())
.collect::<Vec<_>>()
.join(", ");
return Err(DoomError::message(format!(
"Could not find SAMUEL export for any of these paths under {}: {tried}",
samuel_export_root.display()
)));
}
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(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(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_texture.display()
)));
}
Ok(best_candidates.into_iter().next().expect("non-empty best candidate list"))
}
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
}