diff --git a/Penumbra/Import/Models/Export/MaterialExporter.cs b/Penumbra/Import/Models/Export/MaterialExporter.cs index 61609bb5..307e9d2b 100644 --- a/Penumbra/Import/Models/Export/MaterialExporter.cs +++ b/Penumbra/Import/Models/Export/MaterialExporter.cs @@ -22,6 +22,12 @@ public class MaterialExporter // variant? } + /// Dependency-less material configuration, for use when no material data can be resolved. + public static readonly MaterialBuilder Unknown = new MaterialBuilder("UNKNOWN") + .WithMetallicRoughnessShader() + .WithDoubleSide(true) + .WithBaseColor(Vector4.One); + /// Build a glTF material from a hydrated XIV model, with the provided name. public static MaterialBuilder Export(Material material, string name, IoNotifier notifier) { diff --git a/Penumbra/Import/Models/Export/ModelExporter.cs b/Penumbra/Import/Models/Export/ModelExporter.cs index 550aaf11..9bc33697 100644 --- a/Penumbra/Import/Models/Export/ModelExporter.cs +++ b/Penumbra/Import/Models/Export/ModelExporter.cs @@ -55,9 +55,14 @@ public class ModelExporter /// Build materials for each of the material slots in the .mdl. private static MaterialBuilder[] ConvertMaterials(MdlFile mdl, Dictionary rawMaterials, IoNotifier notifier) => mdl.Materials - // TODO: material generation should be fallible, which means this lookup should be a tryget, with a fallback. - // fallback can likely be a static on the material exporter. - .Select(name => MaterialExporter.Export(rawMaterials[name], name, notifier.WithContext($"Material {name}"))) + .Select(name => + { + if (rawMaterials.TryGetValue(name, out var rawMaterial)) + return MaterialExporter.Export(rawMaterial, name, notifier.WithContext($"Material {name}")); + + notifier.Warning($"Material \"{name}\" missing, using blank fallback."); + return MaterialExporter.Unknown; + }) .ToArray(); /// Convert XIV skeleton data into a glTF-compatible node tree, with mappings. diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index c41f28e5..bfd55281 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -37,7 +37,7 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect _tasks.Clear(); } - public Task ExportToGltf(MdlFile mdl, IEnumerable sklbPaths, Func read, string outputPath) + public Task ExportToGltf(MdlFile mdl, IEnumerable sklbPaths, Func read, string outputPath) => EnqueueWithResult( new ExportToGltfAction(this, mdl, sklbPaths, read, outputPath), action => action.Notifier @@ -106,7 +106,7 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect } /// Try to resolve the absolute path to a .mtrl from the potentially-partial path provided by a model. - private string ResolveMtrlPath(string rawPath) + private string? ResolveMtrlPath(string rawPath, IoNotifier notifier) { // TODO: this should probably be chosen in the export settings var variantId = 1; @@ -119,13 +119,18 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect ? rawPath : '/' + Path.GetFileName(rawPath); - // TODO: this should be a recoverable warning if (absolutePath == null) - throw new Exception("Failed to resolve material path."); + { + notifier.Warning($"Material path \"{rawPath}\" could not be resolved."); + return null; + } var info = parser.GetFileInfo(absolutePath); if (info.FileType is not FileType.Material) - throw new Exception($"Material path {rawPath} does not conform to material conventions."); + { + notifier.Warning($"Material path {rawPath} does not conform to material conventions."); + return null; + } var resolvedPath = info.ObjectType switch { @@ -179,7 +184,7 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect ModelManager manager, MdlFile mdl, IEnumerable sklbPaths, - Func read, + Func read, string outputPath) : IAction { @@ -193,10 +198,10 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect var xivSkeletons = BuildSkeletons(cancel); Penumbra.Log.Debug("[GLTF Export] Reading materials..."); - var materials = mdl.Materials.ToDictionary( - path => path, - path => BuildMaterial(path, cancel) - ); + var materials = mdl.Materials + .Select(path => (path, material: BuildMaterial(path, Notifier, cancel))) + .Where(pair => pair.material != null) + .ToDictionary(pair => pair.path, pair => pair.material!.Value); Penumbra.Log.Debug("[GLTF Export] Converting model..."); var model = ModelExporter.Export(mdl, xivSkeletons, materials, Notifier); @@ -215,7 +220,9 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect private IEnumerable BuildSkeletons(CancellationToken cancel) { var havokTasks = sklbPaths - .Select(path => new SklbFile(read(path))) + .Select(path => read(path) ?? throw new Exception( + $"Resolved skeleton \"{path}\" could not be read. Ensure EST metadata is configured, and/or relevant mods are enabled in the current collection.")) + .Select(bytes => new SklbFile(bytes)) .WithIndex() .Select(CreateHavokTask) .ToArray(); @@ -234,10 +241,15 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect } /// Read a .mtrl and populate its textures. - private MaterialExporter.Material BuildMaterial(string relativePath, CancellationToken cancel) + private MaterialExporter.Material? BuildMaterial(string relativePath, IoNotifier notifier, CancellationToken cancel) { - var path = manager.ResolveMtrlPath(relativePath); - var mtrl = new MtrlFile(read(path)); + var path = manager.ResolveMtrlPath(relativePath, notifier); + if (path == null) + return null; + var bytes = read(path); + if (bytes == null) + return null; + var mtrl = new MtrlFile(bytes); return new MaterialExporter.Material { @@ -254,12 +266,23 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect { // Work out the texture's path - the DX11 material flag controls a file name prefix. GamePaths.Tex.HandleDx11Path(texture, out var texturePath); - using var textureData = new MemoryStream(read(texturePath)); + var bytes = read(texturePath); + if (bytes == null) + return CreateDummyImage(); + + using var textureData = new MemoryStream(bytes); var image = TexFileParser.Parse(textureData); var pngImage = TextureManager.ConvertToPng(image, cancel).AsPng; return pngImage ?? throw new Exception("Failed to convert texture to png."); } + private Image CreateDummyImage() + { + var image = new Image(1, 1); + image[0, 0] = Color.White; + return image; + } + public bool Equals(IAction? other) { if (other is not ExportToGltfAction rhs) diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs index 6decd344..cb8e662f 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs @@ -260,7 +260,7 @@ public partial class ModEditWindow /// Read a file from the active collection or game. /// Game path to the file to load. // TODO: Also look up files within the current mod regardless of mod state? - private byte[] ReadFile(string path) + private byte[]? ReadFile(string path) { // TODO: if cross-collection lookups are turned off, this conversion can be skipped if (!Utf8GamePath.FromString(path, out var utf8Path, true)) @@ -269,13 +269,9 @@ public partial class ModEditWindow var resolvedPath = _edit._activeCollections.Current.ResolvePath(utf8Path) ?? new FullPath(utf8Path); // TODO: is it worth trying to use streams for these instead? I'll need to do this for mtrl/tex too, so might be a good idea. that said, the mtrl reader doesn't accept streams, so... - var bytes = resolvedPath.IsRooted + return resolvedPath.IsRooted ? File.ReadAllBytes(resolvedPath.FullName) : _edit._gameData.GetFile(resolvedPath.InternalName.ToString())?.Data; - - // TODO: some callers may not care about failures - handle exceptions separately? - return bytes ?? throw new Exception( - $"Resolved path {path} could not be found. If modded, is it enabled in the current collection?"); } /// Remove the material given by the index.