From 4590538d0c5454b0ab806305d720eb8573360f75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tibor=20Ili=C4=87?= Date: Mon, 14 Sep 2026 21:15:32 +0200 Subject: [PATCH 1/8] Fix swapped DDS color channels --- .../Editors.ImportExport/TextureHelper.cs | 38 ++--------- .../Test.ImportExport/TextureHelperTests.cs | 64 +++++++++++++++++++ 2 files changed, 70 insertions(+), 32 deletions(-) create mode 100644 Editors/ImportExportEditor/Test.ImportExport/TextureHelperTests.cs diff --git a/Editors/ImportExportEditor/Editors.ImportExport/TextureHelper.cs b/Editors/ImportExportEditor/Editors.ImportExport/TextureHelper.cs index c2668933e..ddcfadb4c 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/TextureHelper.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/TextureHelper.cs @@ -29,41 +29,15 @@ public static byte[] ConvertDdsToPng(byte[] ddsbyteSteam) throw new NotSupportedException($"Unsupported DDS format: {image.Format}"); } - // Pfim returns BGRA data for Rgba32, but Bitmap expects ARGB - // We need to swap the R and B channels - byte[] correctedData = new byte[image.DataLen]; - - if (image.Format == Pfim.ImageFormat.Rgba32) - { - // BGRA -> ARGB conversion - for (int i = 0; i < image.DataLen; i += 4) - { - correctedData[i] = image.Data[i + 2]; // B -> R - correctedData[i + 1] = image.Data[i + 1]; // G -> G - correctedData[i + 2] = image.Data[i]; // R -> B - correctedData[i + 3] = image.Data[i + 3]; // A -> A - } - } - else if (image.Format == Pfim.ImageFormat.Rgb24) - { - // BGR -> RGB conversion - for (int i = 0; i < image.DataLen; i += 3) - { - correctedData[i] = image.Data[i + 2]; // B -> R - correctedData[i + 1] = image.Data[i + 1]; // G -> G - correctedData[i + 2] = image.Data[i]; // R -> B - } - } - else - { - // For other formats, use the data as-is - correctedData = image.Data; - } - using var bitmap = new Bitmap(image.Width, image.Height, pixelFormat); var bitmapData = bitmap.LockBits(new Rectangle(0, 0, image.Width, image.Height), ImageLockMode.WriteOnly, pixelFormat); - System.Runtime.InteropServices.Marshal.Copy(correctedData, 0, bitmapData.Scan0, correctedData.Length); + // Pfim's decoded data is already laid out in the byte order expected by + // the corresponding Bitmap pixel format (BGRA for Format32bppArgb and + // BGR for Format24bppRgb). The bitmap encoder interprets the locked + // memory using that same layout, so swapping R/B here would invert the + // exported image channels. + System.Runtime.InteropServices.Marshal.Copy(image.Data, 0, bitmapData.Scan0, image.DataLen); bitmap.UnlockBits(bitmapData); using var b = new MemoryStream(); diff --git a/Editors/ImportExportEditor/Test.ImportExport/TextureHelperTests.cs b/Editors/ImportExportEditor/Test.ImportExport/TextureHelperTests.cs new file mode 100644 index 000000000..2a8d3fe34 --- /dev/null +++ b/Editors/ImportExportEditor/Test.ImportExport/TextureHelperTests.cs @@ -0,0 +1,64 @@ +using System.Drawing; +using System.IO; +using System.Text; +using MeshImportExport; + +namespace Test.ImportExport; + +public class TextureHelperTests +{ + [Test] + public void ConvertDdsToPngPreservesRedForSolidDxt1Texture() + { + var png = TextureHelper.ConvertDdsToPng(CreateSolidRedDxt1Dds()); + + using var pngStream = new MemoryStream(png); + using var bitmap = new Bitmap(pngStream); + var pixel = bitmap.GetPixel(0, 0); + + Assert.That(pixel.R, Is.GreaterThan(200)); + Assert.That(pixel.G, Is.LessThan(20)); + Assert.That(pixel.B, Is.LessThan(20)); + } + + private static byte[] CreateSolidRedDxt1Dds() + { + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream, Encoding.ASCII, leaveOpen: true); + + writer.Write(Encoding.ASCII.GetBytes("DDS ")); + writer.Write(124); // DDS_HEADER.dwSize + writer.Write(0x00081007); // CAPS | HEIGHT | WIDTH | PIXELFORMAT | LINEARSIZE + writer.Write(4); // dwHeight + writer.Write(4); // dwWidth + writer.Write(8); // dwPitchOrLinearSize: one DXT1 block + writer.Write(0); // dwDepth + writer.Write(0); // dwMipMapCount + + for (var i = 0; i < 11; i++) + writer.Write(0); // dwReserved1 + + writer.Write(32); // DDS_PIXELFORMAT.dwSize + writer.Write(0x00000004); // DDPF_FOURCC + writer.Write(Encoding.ASCII.GetBytes("DXT1")); + writer.Write(0); // dwRGBBitCount + writer.Write(0); // dwRBitMask + writer.Write(0); // dwGBitMask + writer.Write(0); // dwBBitMask + writer.Write(0); // dwABitMask + + writer.Write(0x00001000); // DDSCAPS_TEXTURE + writer.Write(0); // dwCaps2 + writer.Write(0); // dwCaps3 + writer.Write(0); // dwCaps4 + writer.Write(0); // dwReserved2 + + // DXT1 endpoint 0 is solid red, endpoint 1 is black, and all pixels + // select endpoint 0. + writer.Write((ushort)0xF800); // RGB565 red + writer.Write((ushort)0x0000); // RGB565 black + writer.Write(0u); // four 2-bit selectors, all zero + + return stream.ToArray(); + } +} From 3798f0bfcdc6f8c111ce805c21f58352821714d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tibor=20Ili=C4=87?= Date: Mon, 14 Sep 2026 14:34:35 +0200 Subject: [PATCH 2/8] Add WSModel and VMD glTF/GLB export --- Editors/BmdEditor/Services/BmdSceneCreator.cs | 34 +- .../Editors.ImportExport/Assembly.cs | 3 + .../RmvToGltf/Helpers/GltfAnimationBuilder.cs | 2 +- .../RmvToGltf/Helpers/GltfMeshBuilder.cs | 73 +++- .../RmvToGltf/Helpers/GltfSceneSaver.cs | 41 +- .../Helpers/GltfStaticMeshBuilder.cs | 41 +- .../RmvToGltf/Helpers/GltfTextureHandler.cs | 288 +++++++++--- .../Exporters/RmvToGltf/RmvToGltfExporter.cs | 409 +++++++++++++++-- .../RmvToGltf/RmvToGltfStaticExporter.cs | 35 +- .../Presentation/ExporterCoreViewModel.cs | 2 +- .../Presentation/IExporterViewModel.cs | 1 + .../RmvToGltf/RmvToGltfExporterViewModel.cs | 1 + .../RmvToGltfStaticExporterViewModel.cs | 1 + .../Misc/FileExtensionHelper.cs | 3 +- .../RmvToGlft/GltfSceneAttachmentTests.cs | 211 +++++++++ .../RmvToGlft/GltfSceneSaverTests.cs | 126 ++++++ .../GltfTextureExportSessionTests.cs | 289 ++++++++++++ .../RmvToGlft/RmvToGltfExporterTests.cs | 180 +++++++- .../RmvToGlft/VmdSkeletonExportTests.cs | 255 +++++++++++ .../Services/KitbashSceneCreator.cs | 23 +- .../DependencyInjectionContainer.cs | 2 + .../SceneNodes/Rmv2ModelNodeLoader.cs | 51 ++- .../Services/ComplexMeshLoader.cs | 49 ++- .../Services/ModelAssetResolver.cs | 413 ++++++++++++++++++ .../VariantMeshCompositionResolver.cs | 379 ++++++++++++++++ .../Services/WsModelMaterialProvider.cs | 23 +- .../Services/ModelAssetResolverTests.cs | 83 ++++ .../VariantMeshCompositionResolverTests.cs | 182 ++++++++ Shared/GameFiles/Vmd/VariantMeshDefinition.cs | 12 +- Testing/Shared/TestUtility/PathHelper.cs | 70 ++- Testing/Shared/TestUtility/PathHelperTests.cs | 24 + 31 files changed, 3068 insertions(+), 238 deletions(-) create mode 100644 Editors/ImportExportEditor/Editors.ImportExport/Assembly.cs create mode 100644 Editors/ImportExportEditor/Test.ImportExport/Exporting/Exporters/RmvToGlft/GltfSceneAttachmentTests.cs create mode 100644 Editors/ImportExportEditor/Test.ImportExport/Exporting/Exporters/RmvToGlft/GltfSceneSaverTests.cs create mode 100644 Editors/ImportExportEditor/Test.ImportExport/Exporting/Exporters/RmvToGlft/GltfTextureExportSessionTests.cs create mode 100644 Editors/ImportExportEditor/Test.ImportExport/Exporting/Exporters/RmvToGlft/VmdSkeletonExportTests.cs create mode 100644 GameWorld/GameWorldCore/GameWorld.Core/Services/ModelAssetResolver.cs create mode 100644 GameWorld/GameWorldCore/GameWorld.Core/Services/VariantMeshCompositionResolver.cs create mode 100644 GameWorld/GameWorldCore/GameWorld.CoreTest/Services/ModelAssetResolverTests.cs create mode 100644 GameWorld/GameWorldCore/GameWorld.CoreTest/Services/VariantMeshCompositionResolverTests.cs create mode 100644 Testing/Shared/TestUtility/PathHelperTests.cs diff --git a/Editors/BmdEditor/Services/BmdSceneCreator.cs b/Editors/BmdEditor/Services/BmdSceneCreator.cs index 6f9a2f829..b02f68e79 100644 --- a/Editors/BmdEditor/Services/BmdSceneCreator.cs +++ b/Editors/BmdEditor/Services/BmdSceneCreator.cs @@ -26,6 +26,7 @@ public class BmdSceneCreator private readonly IPackFileService _packFileService; private readonly GameWorld.Core.Components.SceneManager _sceneManager; private readonly GameWorld.Core.SceneNodes.Rmv2ModelNodeLoader _rmv2ModelNodeLoader; + private readonly IModelAssetResolver _modelAssetResolver; private readonly ResourceLibrary _resourceLibrary; private readonly GameWorld.Core.Services.MeshBuilderService _meshBuilderService; @@ -38,11 +39,13 @@ public BmdSceneCreator( GameWorld.Core.Components.SceneManager sceneManager, GameWorld.Core.SceneNodes.Rmv2ModelNodeLoader rmv2ModelNodeLoader, ResourceLibrary resourceLibrary, - GameWorld.Core.Services.MeshBuilderService meshBuilderService) + GameWorld.Core.Services.MeshBuilderService meshBuilderService, + IModelAssetResolver? modelAssetResolver = null) { _packFileService = packFileService; _sceneManager = sceneManager; _rmv2ModelNodeLoader = rmv2ModelNodeLoader; + _modelAssetResolver = modelAssetResolver ?? new ModelAssetResolver(packFileService); _resourceLibrary = resourceLibrary; _meshBuilderService = meshBuilderService; } @@ -148,33 +151,12 @@ private SceneNode LoadSingleProp(string propPath, PropInfo propInfo, GroupNode p try { - // Handle wsmodel files by getting the actual rigid_model_v2 path - var actualModelFile = modelFile; - var actualModelPath = propPath; - - if (Path.GetExtension(propPath).ToLower() == ".wsmodel") - { - var wsModel = new WsModelFile(modelFile); - if (string.IsNullOrEmpty(wsModel.GeometryPath)) - { - return CreatePlaceholderProp(propPath, propInfo, propsGroup, instanceIndex, "WsModel has no geometry path"); - } - - actualModelPath = wsModel.GeometryPath; - actualModelFile = _packFileService.FindFile(actualModelPath); - if (actualModelFile == null) - { - return CreatePlaceholderProp(propPath, propInfo, propsGroup, instanceIndex, $"Referenced file not found: {actualModelPath}"); - } - } - - // Load the RMV2 model - var modelData = actualModelFile.DataSource.ReadData(); - var rmv = ModelFactory.Create().Load(modelData); + var resolvedAsset = _modelAssetResolver.Resolve(modelFile); + var rmv = resolvedAsset.Model; // Create RMV2 model nodes - var modelFullPath = _packFileService.GetFullPath(actualModelFile); - var lodNodes = _rmv2ModelNodeLoader.CreateModelNodesFromFile(rmv, modelFullPath, false, null); + var modelFullPath = _packFileService.GetFullPath(resolvedAsset.GeometryFile); + var lodNodes = _rmv2ModelNodeLoader.CreateModelNodesFromAsset(resolvedAsset, modelFullPath, false); var lodNode = lodNodes.FirstOrDefault(); if (lodNode == null) diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Assembly.cs b/Editors/ImportExportEditor/Editors.ImportExport/Assembly.cs new file mode 100644 index 000000000..d865b9854 --- /dev/null +++ b/Editors/ImportExportEditor/Editors.ImportExport/Assembly.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Test.ImportExport")] diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfAnimationBuilder.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfAnimationBuilder.cs index 069b97058..cd3a545fc 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfAnimationBuilder.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfAnimationBuilder.cs @@ -26,7 +26,7 @@ public GltfAnimationBuilder(IPackFileService packFileServoce) _packFileService = packFileServoce; } - public void Build(AnimationFile animSkeleton, RmvToGltfExporterSettings settings, ProcessedGltfSkeleton gltfSkeleton, ModelRoot outputScene) + public virtual void Build(AnimationFile animSkeleton, RmvToGltfExporterSettings settings, ProcessedGltfSkeleton gltfSkeleton, ModelRoot outputScene) { foreach (var animationPackFile in settings.InputAnimationFiles) { diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfMeshBuilder.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfMeshBuilder.cs index 7eb485d4b..bfd4757a2 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfMeshBuilder.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfMeshBuilder.cs @@ -1,6 +1,7 @@ using System.IO; using System.Numerics; using Editors.ImportExport.Common; +using GameWorld.Core.Services; using Shared.GameFormats.RigidModel; using Shared.GameFormats.RigidModel.Vertex; using SharpGLTF.Geometry; @@ -13,6 +14,33 @@ namespace Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers public class GltfMeshBuilder { public List> Build(RmvFile rmv2, List textures, RmvToGltfExporterSettings settings, bool willHaveSkeleton = true) + => Build(rmv2, textures, settings, willHaveSkeleton, null); + + // Keep the original public signature for existing callers and binary + // consumers. Composed VMD exports use the overload below to provide a + // stable per-part name prefix. + public List> Build( + ResolvedModelAsset asset, + List textures, + RmvToGltfExporterSettings settings, + bool willHaveSkeleton = true) + => Build(asset, textures, settings, willHaveSkeleton, null); + + public List> Build( + ResolvedModelAsset asset, + List textures, + RmvToGltfExporterSettings settings, + bool willHaveSkeleton, + string? namePrefix) + => Build(asset.Model, textures, settings, willHaveSkeleton, asset.FirstLod.Select(x => x.Material).ToArray(), namePrefix); + + private List> Build( + RmvFile rmv2, + List textures, + RmvToGltfExporterSettings settings, + bool willHaveSkeleton, + IReadOnlyList? effectiveMaterials, + string? namePrefix = null) { var lodLevel = rmv2.ModelList.First(); var hasSkeleton = willHaveSkeleton && string.IsNullOrWhiteSpace(rmv2.Header.SkeletonName) == false; @@ -22,8 +50,15 @@ public List> Build(RmvFile rmv2, Listx.MeshIndex == i).ToList(); - var gltfMaterial = Create(settings, rmvMesh.Material.ModelName + "_Material", meshTextures); - var gltfMesh = GenerateMesh(rmvMesh.Mesh, rmvMesh.Material.ModelName, gltfMaterial, hasSkeleton, settings.MirrorMesh); + var effectiveMaterial = effectiveMaterials != null && i < effectiveMaterials.Count ? effectiveMaterials[i] : null; + var baseName = string.IsNullOrWhiteSpace(rmvMesh.Material.ModelName) + ? $"Part_{i}" + : rmvMesh.Material.ModelName; + var modelName = string.IsNullOrWhiteSpace(namePrefix) + ? baseName + : $"{namePrefix}_{i:D3}_{baseName}"; + var gltfMaterial = Create(settings, modelName + "_Material", meshTextures, effectiveMaterial); + var gltfMesh = GenerateMesh(rmvMesh.Mesh, modelName, gltfMaterial, hasSkeleton, settings.MirrorMesh); meshes.Add(gltfMesh); } return meshes; @@ -59,17 +94,22 @@ MeshBuilder Generate { glTfvertex = SetVertexInfluences(vertex, glTfvertex); } - else if (hasAnyWeights) + else { - // If some vertices have weights in this mesh we enabled validation. - // Ensure vertices without weights get a default binding so validation passes. + // VertexJoints4 still carries a JOINTS/WEIGHTS attribute + // even for an otherwise rigid mesh. Give those vertices + // a valid neutral binding whenever a skeleton is in the + // scene, including the all-unweighted case, so the + // SharpGLTF validator does not reject zero-sum weights. glTfvertex.Skinning.SetBindings((0, 1), (0, 0), (0, 0), (0, 0)); } } - else if (hasAnyWeights) + else { - // Model has weight data but no skeleton is available. - // Set default binding to prevent validation errors. + // The exporter uses VertexJoints4 for every dynamic mesh, + // even when no skeleton is available. Keep its optional + // weight attribute valid for both weighted and completely + // rigid meshes so SharpGLTF can validate the primitive. glTfvertex.Skinning.SetBindings((0, 1), (0, 0), (0, 0), (0, 0)); } @@ -163,12 +203,23 @@ VertexBuilder SetVer return glTfvertex; } - MaterialBuilder Create(RmvToGltfExporterSettings settings, string materialName, List texturesForModel) + MaterialBuilder Create( + RmvToGltfExporterSettings settings, + string materialName, + List texturesForModel, + ResolvedModelMaterial? effectiveMaterial = null) { var material = new MaterialBuilder(materialName) .WithDoubleSide(true) - .WithMetallicRoughness() - .WithAlpha(AlphaMode.MASK); + .WithMetallicRoughness(); + + // Keep the existing RMV2 export behavior (masked material) when + // the source material has no usable alpha flag. Weighted RMV2 and + // WSModel materials both expose an explicit effective value. + var alphaMode = effectiveMaterial?.HasExplicitAlpha == true + ? (effectiveMaterial.Alpha ? AlphaMode.MASK : AlphaMode.OPAQUE) + : AlphaMode.MASK; + material.WithAlpha(alphaMode); foreach (var texture in texturesForModel) { diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfSceneSaver.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfSceneSaver.cs index 3f119558f..f5ff07736 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfSceneSaver.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfSceneSaver.cs @@ -1,4 +1,6 @@ -using System.Windows; +using System.IO; +using System.Linq; +using System.Windows; using Shared.Core.ErrorHandling.Exceptions; using Shared.Core.Services; using SharpGLTF.Schema2; @@ -9,6 +11,18 @@ namespace Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers public interface IGltfSceneSaver { public void Save(ModelRoot modelRoot, string fullSystemPath); + + /// + /// Saves a scene and, for binary glTF output, removes the exact + /// generated texture intermediates supplied by the exporter after the + /// save succeeds. The default implementation preserves compatibility + /// with existing saver implementations. + /// + public void Save( + ModelRoot modelRoot, + string fullSystemPath, + IReadOnlyCollection generatedTexturePaths) + => Save(modelRoot, fullSystemPath); } public class GltfSceneSaver : IGltfSceneSaver @@ -21,10 +35,31 @@ public GltfSceneSaver(IStandardDialogs exceptionService) } public void Save(ModelRoot modelRoot, string fullSystemPath) - { + => Save(modelRoot, fullSystemPath, Array.Empty()); + + public void Save( + ModelRoot modelRoot, + string fullSystemPath, + IReadOnlyCollection generatedTexturePaths) + { try { - modelRoot.SaveGLTF(fullSystemPath); + // SharpGLTF selects the container from the requested extension: + // .gltf produces the JSON/sidecar form and .glb is embedded. + modelRoot.Save(fullSystemPath); + + if (string.Equals(Path.GetExtension(fullSystemPath), ".glb", StringComparison.OrdinalIgnoreCase)) + { + foreach (var texturePath in generatedTexturePaths + .Where(x => string.Equals(Path.GetExtension(x), ".png", StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase)) + { + // These are exact paths returned by the texture + // handler. Never infer or wildcard-delete filenames. + if (File.Exists(texturePath)) + File.Delete(texturePath); + } + } } catch (Exception ex) { diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfStaticMeshBuilder.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfStaticMeshBuilder.cs index 8be7407b8..a5bc3d99c 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfStaticMeshBuilder.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfStaticMeshBuilder.cs @@ -1,7 +1,9 @@ using System.IO; using System.Numerics; using Editors.ImportExport.Common; +using GameWorld.Core.Services; using Shared.GameFormats.RigidModel; +using Shared.GameFormats.RigidModel.Types; using SharpGLTF.Geometry; using SharpGLTF.Geometry.VertexTypes; using SharpGLTF.Materials; @@ -12,6 +14,16 @@ namespace Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers public class GltfStaticMeshBuilder { public List> Build(RmvFile rmv2, List textures, RmvToGltfExporterSettings settings) + => Build(rmv2, textures, settings, null); + + public List> Build(ResolvedModelAsset asset, List textures, RmvToGltfExporterSettings settings) + => Build(asset.Model, textures, settings, asset.FirstLod.Select(x => x.Material).ToArray()); + + private List> Build( + RmvFile rmv2, + List textures, + RmvToGltfExporterSettings settings, + IReadOnlyList? effectiveMaterials) { var lodLevel = rmv2.ModelList.First(); @@ -20,7 +32,16 @@ public List> Build(RmvFile rmv2, List x.MeshIndex == i).ToList(); - var gltfMaterial = Create(settings, rmvMesh.Material.ModelName + "_Material", meshTextures); + var effectiveMaterial = effectiveMaterials != null && i < effectiveMaterials.Count ? effectiveMaterials[i] : null; + // Masks are exported as auxiliary files, not glTF channels, but + // still participate in the existing masked-material heuristic. + // Prefer the effective material so an explicit empty WSModel + // mask override is not replaced by the RMV fallback. + var maskTexturePath = effectiveMaterial != null + ? effectiveMaterial.GetTexture(TextureType.Mask) + : rmvMesh.Material.GetTexture(TextureType.Mask)?.Path; + var hasMaskTexture = !string.IsNullOrWhiteSpace(maskTexturePath); + var gltfMaterial = Create(settings, rmvMesh.Material.ModelName + "_Material", meshTextures, effectiveMaterial, hasMaskTexture); var gltfMesh = GenerateStaticMesh(rmvMesh.Mesh, rmvMesh.Material.ModelName, gltfMaterial, settings.MirrorMesh); meshes.Add(gltfMesh); } @@ -113,7 +134,7 @@ Vector4 ValidateAndFixTangent(Vector4 tangent, Vector3 normal) tangentXYZ = Vector3.Normalize(tangentXYZ); } - // Ensure tangent handedness is valid (W should be 1, typically 1 for right-handed) + // Ensure tangent handedness is valid (W should be -1, typically 1 for right-handed) float handedness = tangent.W; if (Math.Abs(handedness) < 0.5f) { @@ -144,7 +165,12 @@ Vector3 GeneratePerpendicularVector(Vector3 normal) return Vector3.Normalize(tangent); } - MaterialBuilder Create(RmvToGltfExporterSettings settings, string materialName, List texturesForModel) + MaterialBuilder Create( + RmvToGltfExporterSettings settings, + string materialName, + List texturesForModel, + ResolvedModelMaterial? effectiveMaterial = null, + bool hasMaskTexture = false) { // Option 4: Material Enhancement with proper PBR setup var material = new MaterialBuilder(materialName) @@ -155,7 +181,8 @@ MaterialBuilder Create(RmvToGltfExporterSettings settings, string materialName, bool hasAlphaMaskedTexture = texturesForModel.Any(t => t.HasAlphaChannel); bool hasMaskInName = texturesForModel.Any(t => t.SystemFilePath.Contains("mask", StringComparison.OrdinalIgnoreCase) || - t.SystemFilePath.Contains("_m.", StringComparison.OrdinalIgnoreCase)); + t.SystemFilePath.Contains("_m.", StringComparison.OrdinalIgnoreCase)) + || hasMaskTexture; bool hasTransparency = texturesForModel.Any(t => t.SystemFilePath.Contains("alpha", StringComparison.OrdinalIgnoreCase) || t.SystemFilePath.Contains("transparent", StringComparison.OrdinalIgnoreCase)); @@ -171,7 +198,11 @@ MaterialBuilder Create(RmvToGltfExporterSettings settings, string materialName, materialName.Contains("chain", StringComparison.OrdinalIgnoreCase); // Set appropriate alpha mode - if (hasTransparency) + if (effectiveMaterial?.HasExplicitAlpha == true) + { + material.WithAlpha(effectiveMaterial.Alpha ? AlphaMode.MASK : AlphaMode.OPAQUE); + } + else if (hasTransparency) { material.WithAlpha(AlphaMode.BLEND); } diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs index 564f4cddc..6f35298ec 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs @@ -1,6 +1,9 @@ using System.IO; +using System.Security.Cryptography; +using System.Text; using Editors.ImportExport.Exporting.Exporters.DdsToMaterialPng; using Editors.ImportExport.Exporting.Exporters.DdsToNormalPng; +using GameWorld.Core.Services; using Shared.GameFormats.RigidModel; using Shared.GameFormats.RigidModel.Types; using Shared.Core.PackFiles; @@ -8,21 +11,46 @@ namespace Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers { - public record TextureResult(int MeshIndex, string SystemFilePath, KnownChannel GlftTexureType, bool HasAlphaChannel = false); + public record TextureResult( + int MeshIndex, + string SystemFilePath, + KnownChannel GlftTexureType, + bool HasAlphaChannel = false); public record MaskTextureResult(int MeshIndex, string SystemFilePath); + /// + /// Conversion cache owned by one export operation. Composed VMD exports + /// share this instance across all component models so the same source is + /// converted once and different source paths with the same basename get + /// distinct output files. + /// + public sealed class GltfTextureExportSession + { + public GltfTextureExportSession(bool collisionSafe = true) + { + CollisionSafe = collisionSafe; + } + + internal Dictionary ExportedTextures { get; } = new(StringComparer.OrdinalIgnoreCase); + internal bool CollisionSafe { get; } + } + public interface IGltfTextureHandler { public List HandleTextures(RmvFile rmvFile, RmvToGltfExporterSettings settings); + public List HandleTextures(ResolvedModelAsset asset, RmvToGltfExporterSettings settings) + => HandleTextures(asset.Model, settings); + public List HandleTextures(ResolvedModelAsset asset, RmvToGltfExporterSettings settings, GltfTextureExportSession session) + => HandleTextures(asset, settings); } public class GltfTextureHandler : IGltfTextureHandler { private readonly IDdsToNormalPngExporter _ddsToNormalPngExporter; private readonly IDdsToMaterialPngExporter _ddsToMaterialPngExporter; - private readonly IPackFileService _packFileService; + private readonly IPackFileService? _packFileService; - public GltfTextureHandler(IDdsToNormalPngExporter ddsToNormalPngExporter, IDdsToMaterialPngExporter ddsToMaterialPngExporter, IPackFileService packFileService = null) + public GltfTextureHandler(IDdsToNormalPngExporter ddsToNormalPngExporter, IDdsToMaterialPngExporter ddsToMaterialPngExporter, IPackFileService? packFileService = null) { _ddsToNormalPngExporter = ddsToNormalPngExporter; _ddsToMaterialPngExporter = ddsToMaterialPngExporter; @@ -37,7 +65,7 @@ public List HandleTextures(RmvFile rmvFile, RmvToGltfExporterSett if (!settings.ExportMaterials) return output; - var exportedTextures = new Dictionary(); // To avoid exporting same texture multiple times + var session = new GltfTextureExportSession(collisionSafe: false); int lodICounnt = 1; for (var lodIndex = 0; lodIndex < lodICounnt; lodIndex++) @@ -49,21 +77,7 @@ public List HandleTextures(RmvFile rmvFile, RmvToGltfExporterSett foreach (var tex in textures) { - switch (tex.Type) - { - case TextureType.Normal: DoTextureConversionNormalMap(settings, output, exportedTextures, meshIndex, tex); break; - case TextureType.MaterialMap: DoTextureConversionMaterialMap(settings, output, exportedTextures, meshIndex, tex); break; - case TextureType.BaseColour: - case TextureType.Diffuse: - DoTextureDefault(KnownChannel.BaseColor, settings, output, exportedTextures, meshIndex, tex); - break; - case TextureType.Mask: DoTextureMask(settings, output, exportedTextures, meshIndex, tex); break; - case TextureType.Specular: DoTextureDefault(KnownChannel.SpecularColor, settings, output, exportedTextures, meshIndex, tex); break; - case TextureType.Gloss: DoTextureDefault(KnownChannel.MetallicRoughness, settings, output, exportedTextures, meshIndex, tex); break; - case TextureType.Ambient_occlusion: DoTextureDefault(KnownChannel.Occlusion, settings, output, exportedTextures, meshIndex, tex); break; - case TextureType.Emissive: DoTextureDefault(KnownChannel.Emissive, settings, output, exportedTextures, meshIndex, tex); break; - case TextureType.EmissiveDistortion: DoTextureDefault(KnownChannel.Emissive, settings, output, exportedTextures, meshIndex, tex); break; - } + HandleTexture(settings, output, session, meshIndex, tex); } } @@ -72,6 +86,37 @@ public List HandleTextures(RmvFile rmvFile, RmvToGltfExporterSett return output; } + + /// + /// Exports textures from the effective material for each LOD0 part. A + /// resolved WSModel material contains only its overrides; the resolver + /// has already merged those values with the RMV2 material, matching the + /// viewport's fallback behavior. + /// + public List HandleTextures(ResolvedModelAsset asset, RmvToGltfExporterSettings settings) + => HandleTextures(asset, settings, new GltfTextureExportSession(collisionSafe: false)); + + public List HandleTextures(ResolvedModelAsset asset, RmvToGltfExporterSettings settings, GltfTextureExportSession session) + { + var output = new List(); + + if (!settings.ExportMaterials) + return output; + + foreach (var part in asset.FirstLod) + { + foreach (var texture in part.Material.Textures) + { + if (string.IsNullOrWhiteSpace(texture.Value)) + continue; + + var input = new MaterialBuilderTextureInput(texture.Value, texture.Key); + HandleTexture(settings, output, session, part.PartIndex, input); + } + } + + return output; + } interface IDDsToPngExporter { public string Export(string path, string outputPath, bool convertToBlender) @@ -90,37 +135,139 @@ List ExtractTextures(RmvModel model) record MaterialBuilderTextureInput(string Path, TextureType Type); - private void DoTextureConversionMaterialMap(RmvToGltfExporterSettings settings, List output, Dictionary exportedTextures, int meshIndex, MaterialBuilderTextureInput text) + private static string CacheKey(MaterialBuilderTextureInput texture, string conversion, bool option = false) + => $"{NormalizeTexturePath(texture.Path)}|{conversion}|{option}"; + + private static string NormalizeTexturePath(string path) + => path.Replace('\\', '/').Trim().ToLowerInvariant(); + + private static string FinalizeTexturePath( + GltfTextureExportSession session, + string cacheKey, + string sourcePath, + string? exportedPath) { - if (exportedTextures.ContainsKey(text.Path) == false) - exportedTextures[text.Path] = _ddsToMaterialPngExporter.Export(text.Path, settings.OutputPath, settings.ConvertMaterialTextureToBlender); + if (string.IsNullOrWhiteSpace(exportedPath) || !session.CollisionSafe) + return exportedPath ?? string.Empty; + + var directory = Path.GetDirectoryName(exportedPath) ?? string.Empty; + var extension = Path.GetExtension(exportedPath); + var stem = Path.GetFileNameWithoutExtension(exportedPath); + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes( + $"{NormalizeTexturePath(sourcePath)}|{cacheKey}"))).ToLowerInvariant()[..10]; + var targetPath = Path.Combine(directory, $"{stem}_{hash}{extension}"); + + // The DDS exporters choose their own basename. Move the completed + // file immediately so a later component with the same basename + // cannot overwrite it. Mocks may return a path without a file; + // returning the deterministic target still keeps glTF references + // distinct in those cases. + if (!string.Equals(exportedPath, targetPath, StringComparison.OrdinalIgnoreCase) + && File.Exists(exportedPath)) + { + if (File.Exists(targetPath)) + File.Delete(targetPath); + File.Move(exportedPath, targetPath); + } + + return targetPath; + } - var systemPath = exportedTextures[text.Path]; - if (systemPath != null) + private static string GetCollisionSafeStem( + GltfTextureExportSession session, + string cacheKey, + string sourcePath, + string stem) + { + if (!session.CollisionSafe) + return stem; + + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes( + $"{NormalizeTexturePath(sourcePath)}|{cacheKey}"))).ToLowerInvariant()[..10]; + return $"{stem}_{hash}"; + } + + private void HandleTexture( + RmvToGltfExporterSettings settings, + List output, + GltfTextureExportSession session, + int meshIndex, + MaterialBuilderTextureInput texture) + { + switch (texture.Type) + { + case TextureType.Normal: + DoTextureConversionNormalMap(settings, output, session, meshIndex, texture); + break; + case TextureType.MaterialMap: + DoTextureConversionMaterialMap(settings, output, session, meshIndex, texture); + break; + case TextureType.BaseColour: + case TextureType.Diffuse: + DoTextureDefault(KnownChannel.BaseColor, settings, output, session, meshIndex, texture); + break; + case TextureType.Mask: + DoTextureMask(settings, session, texture); + break; + case TextureType.Specular: + DoTextureDefault(KnownChannel.SpecularColor, settings, output, session, meshIndex, texture); + break; + case TextureType.Gloss: + DoTextureDefault(KnownChannel.MetallicRoughness, settings, output, session, meshIndex, texture); + break; + case TextureType.Ambient_occlusion: + DoTextureDefault(KnownChannel.Occlusion, settings, output, session, meshIndex, texture); + break; + case TextureType.Emissive: + case TextureType.EmissiveDistortion: + DoTextureDefault(KnownChannel.Emissive, settings, output, session, meshIndex, texture); + break; + } + } + + private void DoTextureConversionMaterialMap(RmvToGltfExporterSettings settings, List output, GltfTextureExportSession session, int meshIndex, MaterialBuilderTextureInput text) + { + var cacheKey = CacheKey(text, "material", settings.ConvertMaterialTextureToBlender); + if (session.ExportedTextures.ContainsKey(cacheKey) == false) + { + var exportedPath = _ddsToMaterialPngExporter.Export(text.Path, settings.OutputPath, settings.ConvertMaterialTextureToBlender); + session.ExportedTextures[cacheKey] = FinalizeTexturePath(session, cacheKey, text.Path, exportedPath); + } + + var systemPath = session.ExportedTextures[cacheKey]; + if (string.IsNullOrWhiteSpace(systemPath) == false) output.Add(new TextureResult(meshIndex, systemPath, KnownChannel.MetallicRoughness)); } - private void DoTextureDefault(KnownChannel textureType, RmvToGltfExporterSettings settings, List output, Dictionary exportedTextures, int meshIndex, MaterialBuilderTextureInput text) + private void DoTextureDefault(KnownChannel textureType, RmvToGltfExporterSettings settings, List output, GltfTextureExportSession session, int meshIndex, MaterialBuilderTextureInput text) { - if (exportedTextures.ContainsKey(text.Path) == false) + var cacheKey = CacheKey(text, "default"); + if (session.ExportedTextures.ContainsKey(cacheKey) == false) { - exportedTextures[text.Path] = _ddsToMaterialPngExporter.Export(text.Path, settings.OutputPath, false); + var exportedPath = _ddsToMaterialPngExporter.Export(text.Path, settings.OutputPath, false); + session.ExportedTextures[cacheKey] = FinalizeTexturePath(session, cacheKey, text.Path, exportedPath); // For 3D printing: Export alpha channel as a separate mask for base color/diffuse if (settings.ExportDisplacementMaps && textureType == KnownChannel.BaseColor) { - ExportAlphaMask(text.Path, settings.OutputPath); + ExportAlphaMask( + text.Path, + settings.OutputPath, + session.CollisionSafe + ? GetCollisionSafeStem(session, cacheKey, text.Path, Path.GetFileNameWithoutExtension(text.Path)) + : null); } } - var systemPath = exportedTextures[text.Path]; - if (systemPath != null) - output.Add(new TextureResult(meshIndex, systemPath, textureType, false)); + var systemPath = session.ExportedTextures[cacheKey]; + if (string.IsNullOrWhiteSpace(systemPath) == false) + output.Add(new TextureResult(meshIndex, systemPath, textureType)); } - private void DoTextureMask(RmvToGltfExporterSettings settings, List output, Dictionary exportedTextures, int meshIndex, MaterialBuilderTextureInput text) + private void DoTextureMask(RmvToGltfExporterSettings settings, GltfTextureExportSession session, MaterialBuilderTextureInput text) { - if (exportedTextures.ContainsKey(text.Path) == false) + var cacheKey = CacheKey(text, "mask"); + if (session.ExportedTextures.ContainsKey(cacheKey) == false) { // Export mask as separate PNG - name it with _mask suffix for clarity var exportedPath = _ddsToMaterialPngExporter.Export(text.Path, settings.OutputPath, false); @@ -130,10 +277,11 @@ private void DoTextureMask(RmvToGltfExporterSettings settings, List output, Dictionary exportedTextures, int meshIndex, MaterialBuilderTextureInput text) + private void DoTextureConversionNormalMap(RmvToGltfExporterSettings settings, List output, GltfTextureExportSession session, int meshIndex, MaterialBuilderTextureInput text) { - if (exportedTextures.ContainsKey(text.Path) == false) + var cacheKey = CacheKey(text, "normal", settings.ConvertNormalTextureToBlue || settings.ExportDisplacementMaps); + if (session.ExportedTextures.ContainsKey(cacheKey) == false) { // Only export displacement maps for 3D printing workflow if (settings.ExportDisplacementMaps) { // Export normal map variants with proper YCoCg decoding - ExportNormalMapVariants(text.Path, settings.OutputPath); - ExportDisplacementFromNormalMap(text.Path, settings.OutputPath, settings); + var outputStem = session.CollisionSafe + ? GetCollisionSafeStem(session, cacheKey, text.Path, Path.GetFileNameWithoutExtension(text.Path)) + : Path.GetFileNameWithoutExtension(text.Path); + ExportNormalMapVariants(text.Path, settings.OutputPath, outputStem); + ExportDisplacementFromNormalMap(text.Path, settings.OutputPath, settings, outputStem); // Set the path to the raw normal map - var fileName = Path.GetFileNameWithoutExtension(text.Path); - var outDirectory = Path.GetDirectoryName(settings.OutputPath); - exportedTextures[text.Path] = Path.Combine(outDirectory, fileName + "_raw.png"); + var outDirectory = Path.GetDirectoryName(settings.OutputPath) ?? string.Empty; + var rawNormalPath = Path.Combine(outDirectory, outputStem + "_raw.png"); + session.ExportedTextures[cacheKey] = session.CollisionSafe + ? rawNormalPath + : FinalizeTexturePath(session, cacheKey, text.Path, rawNormalPath); } else { // Regular export: use the standard DDS to PNG exporter - exportedTextures[text.Path] = _ddsToNormalPngExporter.Export(text.Path, settings.OutputPath, settings.ConvertNormalTextureToBlue); + var exportedPath = _ddsToNormalPngExporter.Export(text.Path, settings.OutputPath, settings.ConvertNormalTextureToBlue); + session.ExportedTextures[cacheKey] = FinalizeTexturePath(session, cacheKey, text.Path, exportedPath); } } - var systemPath = exportedTextures[text.Path]; - if (systemPath != null) + var systemPath = session.ExportedTextures[cacheKey]; + if (string.IsNullOrWhiteSpace(systemPath) == false) output.Add(new TextureResult(meshIndex, systemPath, KnownChannel.Normal)); } - private void ExportNormalMapVariants(string packFilePath, string outputPath) + private void ExportNormalMapVariants(string packFilePath, string outputPath, string? outputStem = null) { if (_packFileService == null) return; @@ -231,8 +384,8 @@ private void ExportNormalMapVariants(string packFilePath, string outputPath) if (packFile == null) return; - var fileName = Path.GetFileNameWithoutExtension(packFilePath); - var outDirectory = Path.GetDirectoryName(outputPath); + var fileName = outputStem ?? Path.GetFileNameWithoutExtension(packFilePath); + var outDirectory = Path.GetDirectoryName(outputPath) ?? string.Empty; var bytes = packFile.DataSource.ReadData(); if (bytes != null && bytes.Any()) @@ -242,7 +395,7 @@ private void ExportNormalMapVariants(string packFilePath, string outputPath) } } - private void ExportAlphaMask(string packFilePath, string outputPath) + private void ExportAlphaMask(string packFilePath, string outputPath, string? outputStem = null) { if (_packFileService == null) return; @@ -251,8 +404,8 @@ private void ExportAlphaMask(string packFilePath, string outputPath) if (packFile == null) return; - var fileName = Path.GetFileNameWithoutExtension(packFilePath); - var outDirectory = Path.GetDirectoryName(outputPath); + var fileName = outputStem ?? Path.GetFileNameWithoutExtension(packFilePath); + var outDirectory = Path.GetDirectoryName(outputPath) ?? string.Empty; var bytes = packFile.DataSource.ReadData(); if (bytes == null || !bytes.Any()) @@ -299,10 +452,21 @@ private void ExportAlphaMask(string packFilePath, string outputPath) maskBitmap.Save(maskPath, System.Drawing.Imaging.ImageFormat.Png); } - public void ExportDisplacementFromNormalMap(string normalMapPath, string outputPath, RmvToGltfExporterSettings settings) + // Preserve the original public signature for existing callers. + public void ExportDisplacementFromNormalMap( + string normalMapPath, + string outputPath, + RmvToGltfExporterSettings settings) + => ExportDisplacementFromNormalMap(normalMapPath, outputPath, settings, null); + + public void ExportDisplacementFromNormalMap( + string normalMapPath, + string outputPath, + RmvToGltfExporterSettings settings, + string? outputStem) { - var fileName = Path.GetFileNameWithoutExtension(normalMapPath); - var outDirectory = Path.GetDirectoryName(outputPath); + var fileName = outputStem ?? Path.GetFileNameWithoutExtension(normalMapPath); + var outDirectory = Path.GetDirectoryName(outputPath) ?? string.Empty; if (_packFileService == null) return; diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporter.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporter.cs index aa7949fd6..df5651e1f 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporter.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporter.cs @@ -1,9 +1,10 @@ using System.Windows; +using Editors.ImportExport.Common; using Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers; using Editors.ImportExport.Misc; using GameWorld.Core.Services; using Shared.Core.PackFiles.Models; -using Shared.GameFormats.RigidModel; +using Shared.GameFormats.RigidModel.MaterialHeaders; using SharpGLTF.Geometry; using SharpGLTF.Materials; using SharpGLTF.Schema2; @@ -19,8 +20,40 @@ public class RmvToGltfExporter private readonly GltfSkeletonBuilder _gltfSkeletonBuilder; private readonly GltfAnimationBuilder _gltfAnimationBuilder; private readonly ISkeletonAnimationLookUpHelper _skeletonLookUpHelper; + private readonly IModelAssetResolver _modelAssetResolver; + private readonly IVariantMeshCompositionResolver? _variantMeshResolver; - public RmvToGltfExporter(IGltfSceneSaver gltfSaver, GltfMeshBuilder gltfMeshBuilder, IGltfTextureHandler gltfTextureHandler, GltfSkeletonBuilder gltfSkeletonsBuilder, GltfAnimationBuilder gltfAnimationCreator, ISkeletonAnimationLookUpHelper skeletonLookUpHelper) + // Keep the pre-composition constructor signature intact. The + // composition resolver is an additive dependency for VMD exports. + public RmvToGltfExporter( + IGltfSceneSaver gltfSaver, + GltfMeshBuilder gltfMeshBuilder, + IGltfTextureHandler gltfTextureHandler, + GltfSkeletonBuilder gltfSkeletonsBuilder, + GltfAnimationBuilder gltfAnimationCreator, + ISkeletonAnimationLookUpHelper skeletonLookUpHelper, + IModelAssetResolver? modelAssetResolver = null) + : this( + gltfSaver, + gltfMeshBuilder, + gltfTextureHandler, + gltfSkeletonsBuilder, + gltfAnimationCreator, + skeletonLookUpHelper, + modelAssetResolver, + null) + { + } + + public RmvToGltfExporter( + IGltfSceneSaver gltfSaver, + GltfMeshBuilder gltfMeshBuilder, + IGltfTextureHandler gltfTextureHandler, + GltfSkeletonBuilder gltfSkeletonsBuilder, + GltfAnimationBuilder gltfAnimationCreator, + ISkeletonAnimationLookUpHelper skeletonLookUpHelper, + IModelAssetResolver? modelAssetResolver, + IVariantMeshCompositionResolver? variantMeshResolver) { _gltfSaver = gltfSaver; _gltfMeshBuilder = gltfMeshBuilder; @@ -28,14 +61,16 @@ public RmvToGltfExporter(IGltfSceneSaver gltfSaver, GltfMeshBuilder gltfMeshBuil _gltfSkeletonBuilder = gltfSkeletonsBuilder; _gltfAnimationBuilder = gltfAnimationCreator; _skeletonLookUpHelper = skeletonLookUpHelper; + _modelAssetResolver = modelAssetResolver ?? new ModelAssetResolver(); + _variantMeshResolver = variantMeshResolver; } internal ExportSupportEnum CanExportFile(PackFile file) { - if (FileExtensionHelper.IsRmvFile(file.Name)) + if (FileExtensionHelper.IsRmvFile(file.Name) + || FileExtensionHelper.IsWsModelFile(file.Name) + || IsVariantMeshDefinition(file)) return ExportSupportEnum.HighPriority; - if (FileExtensionHelper.IsWsModelFile(file.Name)) - return ExportSupportEnum.NotSupported; // This should be supported in the future return ExportSupportEnum.NotSupported; } @@ -43,56 +78,340 @@ public void Export(RmvToGltfExporterSettings settings) { LogSettings(settings); - var rmv2 = new ModelFactory().Load(settings.InputModelFile.DataSource.ReadData()); + if (IsVariantMeshDefinition(settings.InputModelFile)) + { + ExportVariantMesh(settings); + return; + } + + var resolvedAsset = _modelAssetResolver.Resolve(settings.InputModelFile); + foreach (var diagnostic in resolvedAsset.Diagnostics) + _logger.Here().Warning(diagnostic); + var outputScene = ModelRoot.CreateModel(); + var modelPart = new ExportModelPart(resolvedAsset, string.Empty, "model", true, true); + // Keep the established direct RMV/WS behavior: those exports only + // request a skeleton when animation export is enabled. VMD + // composition intentionally decouples these choices below so slot + // attachments can still use a shared skeleton without clips. + ProcessedGltfSkeleton? skeleton = null; + global::Shared.GameFormats.Animation.AnimationFile? skeletonFile = null; + if (settings.ExportAnimations) + { + skeleton = CreateSharedSkeleton( + [modelPart], + settings, + outputScene, + warnWhenMissing: true, + out skeletonFile, + out var exportCancelled); + // Preserve the direct exporter behavior: choosing No in the + // missing-skeleton warning aborts without saving an output. + if (exportCancelled) + return; + } + if (skeleton != null && skeletonFile != null && settings.ExportAnimations) + _gltfAnimationBuilder.Build(skeletonFile, settings, skeleton, outputScene); + var textureSession = new GltfTextureExportSession(collisionSafe: false); + var textures = _gltfTextureHandler.HandleTextures(resolvedAsset, settings, textureSession); + var meshes = BuildMeshes(modelPart, textures, settings, skeleton != null); + + BuildGltfScene( + meshes, + skeleton, + settings, + outputScene, + textures.Select(x => x.SystemFilePath).ToArray()); + } + + private void ExportVariantMesh(RmvToGltfExporterSettings settings) + { + if (_variantMeshResolver == null) + throw new InvalidOperationException("VariantMeshDefinition export requires the variant mesh composition resolver."); + + var composition = _variantMeshResolver.Resolve(settings.InputModelFile); + foreach (var diagnostic in composition.Diagnostics) + _logger.Here().Warning(diagnostic); + + if (!composition.HasRenderableContent || composition.Root == null) + { + var details = composition.Diagnostics.Count == 0 + ? "No renderable model candidates were found." + : string.Join(Environment.NewLine, composition.Diagnostics); + throw new InvalidOperationException($"Unable to resolve VariantMeshDefinition '{settings.InputModelFile.Name}'. {details}"); + } + + var modelParts = FlattenModelParts(composition.Root); + if (modelParts.Count == 0) + throw new InvalidOperationException($"VariantMeshDefinition '{settings.InputModelFile.Name}' contains no renderable models."); + + var outputScene = ModelRoot.CreateModel(); + var skeleton = CreateSharedSkeleton( + modelParts, + settings, + outputScene, + warnWhenMissing: false, + out var skeletonFile, + out _); + modelParts = ApplySharedSkeletonCompatibility(modelParts, skeleton); + var textureSession = new GltfTextureExportSession(collisionSafe: true); + var meshes = new List(); + var generatedTexturePaths = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var modelPart in modelParts) + { + var textures = _gltfTextureHandler.HandleTextures(modelPart.Asset, settings, textureSession); + generatedTexturePaths.UnionWith(textures.Select(x => x.SystemFilePath)); + meshes.AddRange(BuildMeshes(modelPart, textures, settings, skeleton != null && modelPart.UseSharedSkeleton)); + } + + // Skeleton creation and animation export are intentionally separate: + // attachments and skinning remain useful when animation export is + // disabled, while selected animations are still emitted exactly once. + if (skeleton != null && skeletonFile != null && settings.ExportAnimations) + _gltfAnimationBuilder.Build(skeletonFile, settings, skeleton, outputScene); - // Determine skeleton availability before building meshes to avoid weight validation issues - bool willHaveSkeleton = false; - ProcessedGltfSkeleton? gltfSkeleton = null; - if (settings.ExportAnimations && !string.IsNullOrEmpty(rmv2.Header.SkeletonName)) + _logger.Here().Information($"VMD Export - Parts={modelParts.Count} MeshCount={meshes.Count} Skeleton={skeleton?.Data.Count}"); + BuildGltfScene(meshes, skeleton, settings, outputScene, generatedTexturePaths); + } + + private ProcessedGltfSkeleton? CreateSharedSkeleton( + IReadOnlyList modelParts, + RmvToGltfExporterSettings settings, + ModelRoot outputScene, + bool warnWhenMissing, + out global::Shared.GameFormats.Animation.AnimationFile? skeletonFile, + out bool exportCancelled) + { + skeletonFile = null; + exportCancelled = false; + var skeletonName = modelParts + .Select(x => x.Asset.Model.Header.SkeletonName) + .FirstOrDefault(x => string.IsNullOrWhiteSpace(x) == false); + if (string.IsNullOrWhiteSpace(skeletonName)) + return null; + + foreach (var otherSkeletonName in modelParts + .Select(x => x.Asset.Model.Header.SkeletonName) + .Where(x => string.IsNullOrWhiteSpace(x) == false) + .Distinct(StringComparer.OrdinalIgnoreCase)) { - var skeletonAnimFile = _skeletonLookUpHelper.GetSkeletonFileFromName(rmv2.Header.SkeletonName); - if (skeletonAnimFile == null) + if (!string.Equals(otherSkeletonName, skeletonName, StringComparison.OrdinalIgnoreCase)) { - if (MessageBox.Show( - "Skeleton file not found, \n(Have you loaded all CA pakcs for the right game?)\n Do you want to continue exporting without skeleton/animations?", - "Warning!", - MessageBoxButton.YesNo, MessageBoxImage.Warning) == MessageBoxResult.No) - return; + _logger.Here().Warning($"Composed models use different skeletons ('{skeletonName}' and '{otherSkeletonName}'); using '{skeletonName}' for the shared glTF skeleton."); + break; } - else + } + + skeletonFile = _skeletonLookUpHelper.GetSkeletonFileFromName(skeletonName); + if (skeletonFile == null) + { + var message = $"Skeleton '{skeletonName}' was not found; exporting without a glTF skeleton."; + _logger.Here().Warning(message); + if (warnWhenMissing && settings.ExportAnimations + && MessageBox.Show( + "Skeleton file not found, \n(Have you loaded all CA pakcs for the right game?)\n Do you want to continue exporting without skeleton/animations?", + "Warning!", MessageBoxButton.YesNo, MessageBoxImage.Warning) == MessageBoxResult.No) { - willHaveSkeleton = true; - gltfSkeleton = _gltfSkeletonBuilder.CreateSkeleton(skeletonAnimFile, outputScene, settings); - _gltfAnimationBuilder.Build(skeletonAnimFile, settings, gltfSkeleton, outputScene); + exportCancelled = true; + return null; } - } - - var textures = _gltfTextureHandler.HandleTextures(rmv2, settings); - - var meshes = _gltfMeshBuilder.Build(rmv2, textures, settings, willHaveSkeleton); - - _logger.Here().Information($"MeshCount={meshes.Count()} TextureCount={textures.Count()} Skeleton={gltfSkeleton?.Data.Count}"); - BuildGltfScene(meshes, gltfSkeleton, settings, outputScene); + return null; + } + + var gltfSkeleton = _gltfSkeletonBuilder.CreateSkeleton(skeletonFile, outputScene, settings); + return gltfSkeleton; + } + + private List FlattenModelParts(ResolvedVariantMeshNode node) + { + var output = new List(); + var nextIndex = 0; + AppendModelParts(node, string.Empty, ref nextIndex, output); + return output; } - void BuildGltfScene(List> meshBuilders, ProcessedGltfSkeleton? gltfSkeleton, RmvToGltfExporterSettings settings, ModelRoot outputScene) + private void AppendModelParts( + ResolvedVariantMeshNode node, + string attachmentPoint, + ref int nextIndex, + List output) + { + if (node.ModelAsset != null) + { + output.Add(new ExportModelPart( + node.ModelAsset, + attachmentPoint, + $"vmd_part_{nextIndex++:D3}", + false, + true)); + } + + if (node.ResolvedModelReference != null) + { + AppendModelParts(node.ResolvedModelReference, attachmentPoint, ref nextIndex, output); + } + + foreach (var slot in node.Slots) + { + if (slot.SelectedChild == null) + continue; + + AppendModelParts(slot.SelectedChild, slot.AttachmentPoint, ref nextIndex, output); + } + } + + private List BuildMeshes( + ExportModelPart modelPart, + List textures, + RmvToGltfExporterSettings settings, + bool willHaveSkeleton) + { + var output = new List(); + var meshBuilders = _gltfMeshBuilder.Build( + modelPart.Asset, + textures, + settings, + willHaveSkeleton, + modelPart.NamePrefix == "model" ? null : modelPart.NamePrefix); + + for (var i = 0; i < meshBuilders.Count; i++) + { + var matrixIndex = -1; + if (i < modelPart.Asset.FirstLod.Count + && modelPart.Asset.FirstLod[i].Material.SourceMaterial is WeightedMaterial weightedMaterial) + matrixIndex = weightedMaterial.MatrixIndex; + + var hasWeights = i < modelPart.Asset.FirstLod.Count + && modelPart.Asset.FirstLod[i].Model.Mesh.VertexList.Any(x => x.WeightCount > 0); + var pivotPoint = i < modelPart.Asset.FirstLod.Count + ? VecConv.GetSys(GlobalSceneTransforms.FlipVector( + modelPart.Asset.FirstLod[i].Material.SourceMaterial.PivotPoint, + settings.MirrorMesh)) + : System.Numerics.Vector3.Zero; + output.Add(new ExportedMesh( + meshBuilders[i], + modelPart.AttachmentPoint, + matrixIndex, + hasWeights, + willHaveSkeleton, + modelPart.AllowMatrixAttachment, + pivotPoint)); + } + + return output; + } + + internal void BuildGltfScene( + List meshes, + ProcessedGltfSkeleton? gltfSkeleton, + RmvToGltfExporterSettings settings, + ModelRoot outputScene, + IReadOnlyCollection? generatedTexturePaths = null) { var scene = outputScene.UseScene("default"); - foreach (var meshBuilder in meshBuilders) + foreach (var exportedMesh in meshes) { - var mesh = outputScene.CreateMesh(meshBuilder); + var mesh = outputScene.CreateMesh(exportedMesh.MeshBuilder); + Node? parent = null; if (gltfSkeleton != null) - scene.CreateNode(mesh.Name).WithSkinnedMesh(mesh, gltfSkeleton.Data.ToArray()); + { + var attachmentBone = FindAttachmentBone( + gltfSkeleton, + exportedMesh.AttachmentPoint, + exportedMesh.MatrixIndex, + exportedMesh.AllowMatrixAttachment); + if (attachmentBone != null) + parent = attachmentBone; + } + + var node = parent?.CreateNode(mesh.Name) ?? scene.CreateNode(mesh.Name); + // Rigid attachment is the renderer's precedence rule: an + // attachment resolver plus a valid RMV matrix override means + // the mesh follows that bone as a rigid object, so its vertex + // weights must not apply the same skeleton a second time. + var followsBoneRigidly = parent != null && exportedMesh.MatrixIndex >= 0; + node.WithLocalTranslation(exportedMesh.PivotPoint); + if (gltfSkeleton != null + && exportedMesh.CanUseSkeleton + && exportedMesh.HasWeights + && !followsBoneRigidly) + node.WithSkinnedMesh(mesh, gltfSkeleton.Data.ToArray()); else - scene.CreateNode(mesh.Name).WithMesh(mesh); + node.WithMesh(mesh); } - _gltfSaver.Save(outputScene, settings.OutputPath); + _gltfSaver.Save(outputScene, settings.OutputPath, generatedTexturePaths ?? Array.Empty()); } - void LogSettings(RmvToGltfExporterSettings settings) + internal static Node? FindAttachmentBone( + ProcessedGltfSkeleton skeleton, + string attachmentPoint, + int matrixIndex, + bool allowMatrixIndex = true) + { + if (!string.IsNullOrWhiteSpace(attachmentPoint)) + { + // Match SceneObjectEditor.WireAttachmentResolvers: a named + // attachment is authoritative. An unknown name does not + // fall back to the RMV matrix index. + return skeleton.Data + .Select(x => x.Item1) + .FirstOrDefault(x => string.Equals(x.Name, attachmentPoint, StringComparison.OrdinalIgnoreCase)); + } + + if (allowMatrixIndex && matrixIndex >= 0 && matrixIndex < skeleton.Data.Count) + return skeleton.Data[matrixIndex].Item1; + + return null; + } + + private List ApplySharedSkeletonCompatibility( + List modelParts, + ProcessedGltfSkeleton? skeleton) + { + if (skeleton == null) + return modelParts; + + var sharedSkeletonName = modelParts + .Select(x => x.Asset.Model.Header.SkeletonName) + .FirstOrDefault(x => string.IsNullOrWhiteSpace(x) == false); + if (string.IsNullOrWhiteSpace(sharedSkeletonName)) + return modelParts; + + return modelParts.Select(modelPart => + { + var componentSkeletonName = modelPart.Asset.Model.Header.SkeletonName; + var canUseSharedSkeleton = string.Equals( + componentSkeletonName, + sharedSkeletonName, + StringComparison.OrdinalIgnoreCase); + + if (!canUseSharedSkeleton && string.IsNullOrWhiteSpace(componentSkeletonName) == false) + { + _logger.Here().Warning( + $"VMD component '{modelPart.Asset.InputFile.Name}' uses skeleton '{componentSkeletonName}', " + + $"which differs from shared skeleton '{sharedSkeletonName}'; exporting it without skinning."); + } + + return modelPart with + { + UseSharedSkeleton = canUseSharedSkeleton, + // A component with a different named skeleton cannot + // safely interpret its own MatrixIndex in the shared + // skeleton. Named VMD attachments remain resolvable by + // name; pure MatrixIndex attachments stay unparented. + AllowMatrixAttachment = canUseSharedSkeleton + || string.IsNullOrWhiteSpace(modelPart.AttachmentPoint) == false + }; + }).ToList(); + } + + private static bool IsVariantMeshDefinition(PackFile file) + => file.Name.EndsWith(".variantmeshdefinition", StringComparison.OrdinalIgnoreCase); + + private void LogSettings(RmvToGltfExporterSettings settings) { var str = $"Exporting using {nameof(RmvToGltfExporter)}\n"; str += $"\tInputModelFile:{settings.InputModelFile?.Name}\n"; @@ -102,8 +421,24 @@ void LogSettings(RmvToGltfExporterSettings settings) str += $"\tConvertNormalTextureToBlue:{settings.ConvertNormalTextureToBlue}\n"; str += $"\tExportAnimations:{settings.ExportAnimations}\n"; str += $"\tMirrorMesh:{settings.MirrorMesh}\n"; - + _logger.Here().Information(str); } + + private sealed record ExportModelPart( + ResolvedModelAsset Asset, + string AttachmentPoint, + string NamePrefix, + bool UseSharedSkeleton, + bool AllowMatrixAttachment); + + internal sealed record ExportedMesh( + IMeshBuilder MeshBuilder, + string AttachmentPoint, + int MatrixIndex, + bool HasWeights, + bool CanUseSkeleton, + bool AllowMatrixAttachment, + System.Numerics.Vector3 PivotPoint); } } diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfStaticExporter.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfStaticExporter.cs index d3455e3fa..8c01c5037 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfStaticExporter.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfStaticExporter.cs @@ -1,5 +1,6 @@ using Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers; using Editors.ImportExport.Misc; +using GameWorld.Core.Services; using Shared.Core.PackFiles.Models; using Shared.GameFormats.RigidModel; using SharpGLTF.Geometry; @@ -14,12 +15,18 @@ public class RmvToGltfStaticExporter private readonly IGltfSceneSaver _gltfSaver; private readonly GltfStaticMeshBuilder _gltfMeshBuilder; private readonly IGltfTextureHandler _gltfTextureHandler; + private readonly IModelAssetResolver _modelAssetResolver; - public RmvToGltfStaticExporter(IGltfSceneSaver gltfSaver, GltfStaticMeshBuilder gltfMeshBuilder, IGltfTextureHandler gltfTextureHandler) + public RmvToGltfStaticExporter( + IGltfSceneSaver gltfSaver, + GltfStaticMeshBuilder gltfMeshBuilder, + IGltfTextureHandler gltfTextureHandler, + IModelAssetResolver? modelAssetResolver = null) { _gltfSaver = gltfSaver; _gltfMeshBuilder = gltfMeshBuilder; _gltfTextureHandler = gltfTextureHandler; + _modelAssetResolver = modelAssetResolver ?? new ModelAssetResolver(); } internal ExportSupportEnum CanExportFile(PackFile file) @@ -27,7 +34,7 @@ internal ExportSupportEnum CanExportFile(PackFile file) if (FileExtensionHelper.IsRmvFile(file.Name)) return ExportSupportEnum.Supported; if (FileExtensionHelper.IsWsModelFile(file.Name)) - return ExportSupportEnum.NotSupported; + return ExportSupportEnum.Supported; return ExportSupportEnum.NotSupported; } @@ -35,17 +42,29 @@ public void Export(RmvToGltfExporterSettings settings) { LogSettings(settings); - var rmv2 = new ModelFactory().Load(settings.InputModelFile.DataSource.ReadData()); + var resolvedAsset = _modelAssetResolver.Resolve(settings.InputModelFile); + foreach (var diagnostic in resolvedAsset.Diagnostics) + _logger.Here().Warning(diagnostic); + var outputScene = ModelRoot.CreateModel(); - var textures = _gltfTextureHandler.HandleTextures(rmv2, settings); - var meshes = _gltfMeshBuilder.Build(rmv2, textures, settings); + var textureSession = new GltfTextureExportSession(collisionSafe: false); + var textures = _gltfTextureHandler.HandleTextures(resolvedAsset, settings, textureSession); + var meshes = _gltfMeshBuilder.Build(resolvedAsset, textures, settings); _logger.Here().Information($"Static Export - MeshCount={meshes.Count()} TextureCount={textures.Count()}"); - BuildGltfScene(meshes, settings, outputScene); + BuildGltfScene( + meshes, + settings, + outputScene, + textures.Select(x => x.SystemFilePath).ToArray()); } - void BuildGltfScene(List> meshBuilders, RmvToGltfExporterSettings settings, ModelRoot outputScene) + void BuildGltfScene( + List> meshBuilders, + RmvToGltfExporterSettings settings, + ModelRoot outputScene, + IReadOnlyCollection? generatedTexturePaths = null) { var scene = outputScene.UseScene("default"); foreach (var meshBuilder in meshBuilders) @@ -54,7 +73,7 @@ void BuildGltfScene(List> meshBuilders, RmvToGltfE scene.CreateNode(mesh.Name).WithMesh(mesh); } - _gltfSaver.Save(outputScene, settings.OutputPath); + _gltfSaver.Save(outputScene, settings.OutputPath, generatedTexturePaths ?? Array.Empty()); } void LogSettings(RmvToGltfExporterSettings settings) diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/ExporterCoreViewModel.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/ExporterCoreViewModel.cs index 70b2c9ed1..239665340 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/ExporterCoreViewModel.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/ExporterCoreViewModel.cs @@ -61,7 +61,7 @@ public void BrowsePathCommand() { FileName = Path.GetFileNameWithoutExtension(_inputFile!.Name), DefaultExt = SelectedExporter!.OutputExtension, - Filter = $"File ({SelectedExporter!.OutputExtension})|*{SelectedExporter!.OutputExtension}" + Filter = SelectedExporter!.OutputFilter }; if (dlg.ShowDialog() == true) diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/IExporterViewModel.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/IExporterViewModel.cs index 0c8968866..8f05d449f 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/IExporterViewModel.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/IExporterViewModel.cs @@ -8,6 +8,7 @@ public interface IExporterViewModel { public string DisplayName { get; } string OutputExtension { get; } + string OutputFilter => $"File ({OutputExtension})|*{OutputExtension}"; public void Execute(PackFile exportSource, string outputPath, bool generateImporter); public ExportSupportEnum CanExportFile(PackFile file); diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfExporterViewModel.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfExporterViewModel.cs index f717ef9d3..8d06482c4 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfExporterViewModel.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfExporterViewModel.cs @@ -13,6 +13,7 @@ internal partial class RmvToGltfExporterViewModel : ObservableObject, IExporterV public string DisplayName => "Rmv_to_Gltf"; public string OutputExtension => ".gltf"; + public string OutputFilter => "glTF (*.gltf)|*.gltf|Binary glTF (*.glb)|*.glb"; [ObservableProperty] bool _exportTextures = true; [ObservableProperty] bool _convertMaterialTextureToBlender = true; diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterViewModel.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterViewModel.cs index 40ca0c48c..c6722614c 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterViewModel.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterViewModel.cs @@ -23,6 +23,7 @@ public partial class RmvToGltfStaticExporterViewModel : ObservableObject, IExpor public string DisplayName => "GLTF Static Pose Export"; public string OutputExtension => ".gltf"; + public string OutputFilter => "glTF (*.gltf)|*.gltf|Binary glTF (*.glb)|*.glb"; public RmvToGltfStaticExporterViewModel(RmvToGltfStaticExporter exporter) { diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Misc/FileExtensionHelper.cs b/Editors/ImportExportEditor/Editors.ImportExport/Misc/FileExtensionHelper.cs index 5d1c96310..cf25e81d3 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Misc/FileExtensionHelper.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Misc/FileExtensionHelper.cs @@ -5,7 +5,8 @@ public static class FileExtensionHelper { public static bool IsGltfFile(string fileName) { - return fileName.EndsWith(".gltf", StringComparison.InvariantCultureIgnoreCase); + return fileName.EndsWith(".gltf", StringComparison.InvariantCultureIgnoreCase) || + fileName.EndsWith(".glb", StringComparison.InvariantCultureIgnoreCase); } public static bool IsDdsFile(string fileName) diff --git a/Editors/ImportExportEditor/Test.ImportExport/Exporting/Exporters/RmvToGlft/GltfSceneAttachmentTests.cs b/Editors/ImportExportEditor/Test.ImportExport/Exporting/Exporters/RmvToGlft/GltfSceneAttachmentTests.cs new file mode 100644 index 000000000..015e582b9 --- /dev/null +++ b/Editors/ImportExportEditor/Test.ImportExport/Exporting/Exporters/RmvToGlft/GltfSceneAttachmentTests.cs @@ -0,0 +1,211 @@ +using System.Numerics; +using Editors.ImportExport.Exporting.Exporters.DdsToMaterialPng; +using Editors.ImportExport.Exporting.Exporters.DdsToNormalPng; +using Editors.ImportExport.Exporting.Exporters.RmvToGltf; +using Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers; +using GameWorld.Core.Services; +using Moq; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using SharpGLTF.Geometry; +using SharpGLTF.Geometry.VertexTypes; +using SharpGLTF.Materials; +using SharpGLTF.Schema2; + +namespace Test.ImportExport.Exporting.Exporters.RmvToGlft; + +public class GltfSceneAttachmentTests +{ + [Test] + public void NamedAttachmentWinsAndUnknownNameDoesNotFallBackToMatrixIndex() + { + var namedResult = BuildScene( + CreateWeightedMesh("named_attachment_mesh"), + attachmentPoint: "named_joint", + matrixIndex: 2, + hasWeights: true, + canUseSkeleton: true); + + var namedNode = GetMeshNode(namedResult.Saver.ModelRoot!, "named_attachment_mesh"); + Assert.That(namedNode.VisualParent, Is.SameAs(namedResult.NamedJoint)); + Assert.That(namedNode.Skin, Is.Null, + "A valid attachment plus a matrix override is a rigid renderer attachment, not a second skin transform."); + + var unknownResult = BuildScene( + CreateStaticMesh("unknown_attachment_mesh"), + attachmentPoint: "missing_joint", + matrixIndex: 2, + hasWeights: false, + canUseSkeleton: false); + + var unknownNode = GetMeshNode(unknownResult.Saver.ModelRoot!, "unknown_attachment_mesh"); + Assert.That(unknownNode.VisualParent, Is.Null, + "A named attachment that cannot be resolved must not silently use MatrixIndex."); + } + + [Test] + public void MatrixIndexIsUsedOnlyWhenAttachmentNameIsEmpty() + { + var result = BuildScene( + CreateWeightedMesh("matrix_attachment_mesh"), + attachmentPoint: string.Empty, + matrixIndex: 2, + hasWeights: true, + canUseSkeleton: true); + + var node = GetMeshNode(result.Saver.ModelRoot!, "matrix_attachment_mesh"); + Assert.That(node.VisualParent, Is.SameAs(result.MatrixJoint)); + Assert.That(node.Skin, Is.Null); + } + + [Test] + public void WeightedMeshWithoutAttachmentUsesExactlyOneSharedSkin() + { + var result = BuildScene( + CreateWeightedMesh("weighted_mesh"), + attachmentPoint: string.Empty, + matrixIndex: -1, + hasWeights: true, + canUseSkeleton: true); + + var node = GetMeshNode(result.Saver.ModelRoot!, "weighted_mesh"); + Assert.That(node.Skin, Is.Not.Null); + Assert.That(result.Saver.ModelRoot!.LogicalSkins, Has.Count.EqualTo(1)); + } + + [Test] + public void PivotIsStoredAsMeshNodeLocalTranslation() + { + var result = BuildScene( + CreateStaticMesh("pivot_mesh"), + attachmentPoint: "named_joint", + matrixIndex: -1, + hasWeights: false, + canUseSkeleton: false, + pivotPoint: new Vector3(-2, 3, 4)); + + var node = GetMeshNode(result.Saver.ModelRoot!, "pivot_mesh"); + Assert.That(node.VisualParent, Is.SameAs(result.NamedJoint)); + Assert.That(node.LocalTransform.Translation, Is.EqualTo(new Vector3(-2, 3, 4))); + } + + private static (TestGltfSceneSaver Saver, Node NamedJoint, Node MatrixJoint) BuildScene( + IMeshBuilder meshBuilder, + string attachmentPoint, + int matrixIndex, + bool hasWeights, + bool canUseSkeleton, + Vector3? pivotPoint = null) + { + var saver = new TestGltfSceneSaver(); + var exporter = CreateExporter(saver); + var model = ModelRoot.CreateModel(); + var scene = model.UseScene("default"); + var skeletonRoot = scene.CreateNode("skeleton_root"); + var namedJoint = skeletonRoot.CreateNode("named_joint"); + var matrixJoint = skeletonRoot.CreateNode("matrix_joint"); + var unusedJoint = skeletonRoot.CreateNode("unused_joint"); + var skeleton = new ProcessedGltfSkeleton + { + Data = + [ + (skeletonRoot, Matrix4x4.Identity), + (namedJoint, Matrix4x4.Identity), + (matrixJoint, Matrix4x4.Identity), + (unusedJoint, Matrix4x4.Identity) + ] + }; + + var inputFile = PackFile.CreateFromASCII("test.rigid_model_v2", "test"); + var settings = new RmvToGltfExporterSettings( + inputFile, + [], + Path.Combine(Path.GetTempPath(), "scene-attachment-test.gltf"), + false, + false, + false, + false, + false); + + exporter.BuildGltfScene( + [new RmvToGltfExporter.ExportedMesh( + meshBuilder, + attachmentPoint, + matrixIndex, + hasWeights, + canUseSkeleton, + true, + pivotPoint ?? Vector3.Zero)], + skeleton, + settings, + model); + + Assert.That(saver.ModelRoot, Is.SameAs(model)); + return (saver, namedJoint, matrixJoint); + } + + private static RmvToGltfExporter CreateExporter(TestGltfSceneSaver saver) + { + var packFileService = new Mock().Object; + var normalExporter = new Mock().Object; + var materialExporter = new Mock().Object; + var skeletonLookup = new Mock().Object; + var modelResolver = new Mock().Object; + + return new RmvToGltfExporter( + saver, + new GltfMeshBuilder(), + new GltfTextureHandler(normalExporter, materialExporter), + new GltfSkeletonBuilder(packFileService), + new GltfAnimationBuilder(packFileService), + skeletonLookup, + modelResolver, + null); + } + + private static Node GetMeshNode(ModelRoot model, string name) + => model.LogicalNodes.Single(x => x.Name == name); + + private static IMeshBuilder CreateStaticMesh(string name) + { + var mesh = new MeshBuilder(name); + var primitive = mesh.UsePrimitive(new MaterialBuilder($"{name}_material")); + primitive.AddTriangle( + CreateStaticVertex(new Vector3(0, 0, 0)), + CreateStaticVertex(new Vector3(1, 0, 0)), + CreateStaticVertex(new Vector3(0, 1, 0))); + return mesh; + } + + private static IMeshBuilder CreateWeightedMesh(string name) + { + var mesh = new MeshBuilder(name); + var primitive = mesh.UsePrimitive(new MaterialBuilder($"{name}_material")); + primitive.AddTriangle( + CreateWeightedVertex(new Vector3(0, 0, 0)), + CreateWeightedVertex(new Vector3(1, 0, 0)), + CreateWeightedVertex(new Vector3(0, 1, 0))); + return mesh; + } + + private static VertexBuilder CreateStaticVertex(Vector3 position) + { + var vertex = new VertexBuilder(); + vertex.Geometry.Position = position; + vertex.Geometry.Normal = Vector3.UnitZ; + vertex.Geometry.Tangent = new Vector4(Vector3.UnitX, 1); + vertex.Material.TexCoord = Vector2.Zero; + return vertex; + } + + private static VertexBuilder CreateWeightedVertex(Vector3 position) + { + var vertex = new VertexBuilder(); + vertex.Geometry.Position = position; + vertex.Geometry.Normal = Vector3.UnitZ; + vertex.Geometry.Tangent = new Vector4(Vector3.UnitX, 1); + vertex.Material.TexCoord = Vector2.Zero; + vertex.Skinning.SetBindings((0, 1), (0, 0), (0, 0), (0, 0)); + return vertex; + } +} diff --git a/Editors/ImportExportEditor/Test.ImportExport/Exporting/Exporters/RmvToGlft/GltfSceneSaverTests.cs b/Editors/ImportExportEditor/Test.ImportExport/Exporting/Exporters/RmvToGlft/GltfSceneSaverTests.cs new file mode 100644 index 000000000..acb5e8b96 --- /dev/null +++ b/Editors/ImportExportEditor/Test.ImportExport/Exporting/Exporters/RmvToGlft/GltfSceneSaverTests.cs @@ -0,0 +1,126 @@ +using Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers; +using System.IO; +using Moq; +using Shared.Core.Services; +using SharpGLTF.Schema2; + +namespace Test.ImportExport.Exporting.Exporters.RmvToGlft; + +public class GltfSceneSaverTests +{ + [Test] + public void SavesReloadableGlbWhenOutputUsesGlbExtension() + { + var outputPath = Path.Combine(Path.GetTempPath(), $"asset-editor-{Guid.NewGuid():N}.glb"); + var dialogs = new Mock(); + + try + { + var model = ModelRoot.CreateModel(); + model.UseScene("default"); + var saver = new GltfSceneSaver(dialogs.Object); + + saver.Save(model, outputPath); + + Assert.That(File.Exists(outputPath), Is.True); + ModelRoot.Validate(outputPath); + var reloaded = ModelRoot.Load(outputPath); + Assert.That(reloaded, Is.Not.Null); + dialogs.Verify(x => x.ShowExceptionWindow(It.IsAny()), Times.Never); + } + finally + { + if (File.Exists(outputPath)) + File.Delete(outputPath); + } + } + + [Test] + public void RetainsGeneratedTexturePngForGltfOutput() + { + var outputDirectory = Path.Combine(Path.GetTempPath(), $"asset-editor-gltf-{Guid.NewGuid():N}"); + Directory.CreateDirectory(outputDirectory); + + var outputPath = Path.Combine(outputDirectory, "model.gltf"); + var texturePath = Path.Combine(outputDirectory, "body.png"); + File.WriteAllBytes(texturePath, [1, 2, 3]); + + try + { + var saver = new GltfSceneSaver(new Mock().Object); + var model = ModelRoot.CreateModel(); + model.UseScene("default"); + saver.Save(model, outputPath, [texturePath]); + + Assert.That(File.Exists(outputPath), Is.True); + Assert.That(File.Exists(texturePath), Is.True); + } + finally + { + if (Directory.Exists(outputDirectory)) + Directory.Delete(outputDirectory, recursive: true); + } + } + + [Test] + public void RemovesOnlyReferencedTexturePngsAfterSuccessfulGlbSave() + { + var outputDirectory = Path.Combine(Path.GetTempPath(), $"asset-editor-glb-{Guid.NewGuid():N}"); + Directory.CreateDirectory(outputDirectory); + + var outputPath = Path.Combine(outputDirectory, "model.glb"); + var texturePath = Path.Combine(outputDirectory, "body.png"); + var maskPath = Path.Combine(outputDirectory, "body_mask.png"); + var auxiliaryPath = Path.Combine(outputDirectory, "body_displacement.png"); + File.WriteAllBytes(texturePath, [1, 2, 3]); + File.WriteAllBytes(maskPath, [4, 5, 6]); + File.WriteAllBytes(auxiliaryPath, [7, 8, 9]); + + try + { + var saver = new GltfSceneSaver(new Mock().Object); + var model = ModelRoot.CreateModel(); + model.UseScene("default"); + saver.Save(model, outputPath, [texturePath, texturePath]); + + Assert.That(File.Exists(outputPath), Is.True); + ModelRoot.Validate(outputPath); + Assert.That(File.Exists(texturePath), Is.False); + Assert.That(File.Exists(maskPath), Is.True); + Assert.That(File.Exists(auxiliaryPath), Is.True); + } + finally + { + if (Directory.Exists(outputDirectory)) + Directory.Delete(outputDirectory, recursive: true); + } + } + + [Test] + public void RetainsGeneratedTexturePngWhenGlbSaveFails() + { + var outputDirectory = Path.Combine(Path.GetTempPath(), $"asset-editor-glb-failure-{Guid.NewGuid():N}"); + Directory.CreateDirectory(outputDirectory); + + var outputPath = Path.Combine(outputDirectory, "missing", "model.glb"); + var texturePath = Path.Combine(outputDirectory, "body.png"); + File.WriteAllBytes(texturePath, [1, 2, 3]); + var dialogs = new Mock(); + + try + { + var saver = new GltfSceneSaver(dialogs.Object); + var model = ModelRoot.CreateModel(); + model.UseScene("default"); + saver.Save(model, outputPath, [texturePath]); + + Assert.That(File.Exists(texturePath), Is.True); + dialogs.Verify(x => x.ShowExceptionWindow(It.IsAny()), Times.Once); + } + finally + { + if (Directory.Exists(outputDirectory)) + Directory.Delete(outputDirectory, recursive: true); + } + } +} diff --git a/Editors/ImportExportEditor/Test.ImportExport/Exporting/Exporters/RmvToGlft/GltfTextureExportSessionTests.cs b/Editors/ImportExportEditor/Test.ImportExport/Exporting/Exporters/RmvToGlft/GltfTextureExportSessionTests.cs new file mode 100644 index 000000000..2fd7e7866 --- /dev/null +++ b/Editors/ImportExportEditor/Test.ImportExport/Exporting/Exporters/RmvToGlft/GltfTextureExportSessionTests.cs @@ -0,0 +1,289 @@ +using System.IO; +using Editors.ImportExport.Exporting.Exporters.DdsToMaterialPng; +using Editors.ImportExport.Exporting.Exporters.DdsToNormalPng; +using Editors.ImportExport.Exporting.Exporters.RmvToGltf; +using Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers; +using GameWorld.Core.Services; +using Moq; +using Shared.Core.PackFiles.Models; +using Shared.GameFormats.RigidModel; +using Shared.GameFormats.RigidModel.LodHeader; +using Shared.GameFormats.RigidModel.MaterialHeaders; +using Shared.GameFormats.RigidModel.Types; +using Shared.GameFormats.RigidModel.Vertex; +using SharpGLTF.Materials; +using SharpGLTF.Schema2; + +namespace Test.ImportExport.Exporting.Exporters.RmvToGlft; + +public class GltfTextureExportSessionTests +{ + [Test] + public void ComposedSessionReusesAnIdenticalSourcePath() + { + var outputDirectory = Path.Combine(Path.GetTempPath(), $"asset-editor-textures-{Guid.NewGuid():N}"); + Directory.CreateDirectory(outputDirectory); + + try + { + var materialExporter = new Mock(); + materialExporter + .Setup(x => x.Export(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string source, string output, bool _) => + { + var path = Path.Combine(Path.GetDirectoryName(output)!, "shared.png"); + File.WriteAllBytes(path, System.Text.Encoding.UTF8.GetBytes(source)); + return path; + }); + + var handler = new GltfTextureHandler(new Mock().Object, materialExporter.Object); + var asset = CreateAsset("textures/same/shared.dds", "textures/same/shared.dds"); + var settings = new RmvToGltfExporterSettings( + asset.InputFile, + [], + Path.Combine(outputDirectory, "model.glb"), + true, + false, + false, + false, + false); + + var textures = handler.HandleTextures(asset, settings, new GltfTextureExportSession(collisionSafe: true)); + + Assert.That(textures, Has.Count.EqualTo(2)); + Assert.That(textures.Select(x => x.SystemFilePath).Distinct(StringComparer.OrdinalIgnoreCase).Count(), Is.EqualTo(1)); + materialExporter.Verify(x => x.Export(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + finally + { + if (Directory.Exists(outputDirectory)) + Directory.Delete(outputDirectory, recursive: true); + } + } + + [Test] + public void ComposedSessionDisambiguatesDifferentSourcesWithTheSameBasename() + { + var outputDirectory = Path.Combine(Path.GetTempPath(), $"asset-editor-textures-{Guid.NewGuid():N}"); + Directory.CreateDirectory(outputDirectory); + + try + { + var materialExporter = new Mock(); + materialExporter + .Setup(x => x.Export(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string source, string output, bool _) => + { + var path = Path.Combine(Path.GetDirectoryName(output)!, "shared.png"); + File.WriteAllBytes(path, System.Text.Encoding.UTF8.GetBytes(source)); + return path; + }); + + var normalExporter = new Mock(); + var handler = new GltfTextureHandler(normalExporter.Object, materialExporter.Object); + var asset = CreateAsset( + "textures/first/shared.dds", + "textures/second/shared.dds"); + var settings = new RmvToGltfExporterSettings( + asset.InputFile, + [], + Path.Combine(outputDirectory, "model.glb"), + true, + false, + false, + false, + false); + + var textures = handler.HandleTextures(asset, settings, new GltfTextureExportSession(collisionSafe: true)); + + Assert.That(textures, Has.Count.EqualTo(2)); + Assert.That(textures.Select(x => x.SystemFilePath).Distinct(StringComparer.OrdinalIgnoreCase).Count(), Is.EqualTo(2)); + Assert.That(textures.All(x => File.Exists(x.SystemFilePath)), Is.True); + materialExporter.Verify(x => x.Export(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + finally + { + if (Directory.Exists(outputDirectory)) + Directory.Delete(outputDirectory, recursive: true); + } + } + + [Test] + public void MaskExportDoesNotReplaceBaseColourInFinalGlbMaterial() + { + var outputDirectory = Path.Combine(Path.GetTempPath(), $"asset-editor-textures-{Guid.NewGuid():N}"); + Directory.CreateDirectory(outputDirectory); + + var outputPath = Path.Combine(outputDirectory, "model.glb"); + var baseColourPath = Path.Combine(outputDirectory, "body_base_colour.png"); + var maskPath = Path.Combine(outputDirectory, "body_mask.png"); + File.WriteAllBytes(baseColourPath, OnePixelPng); + + try + { + var materialExporter = new Mock(); + materialExporter + .Setup(x => x.Export(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string source, string output, bool convertToBlender) => + source.EndsWith("base_colour.dds", StringComparison.OrdinalIgnoreCase) + ? baseColourPath + // Deliberately leave this path absent. The production + // handler still invokes the exporter, while avoiding a + // platform image decoder in this channel-binding test. + : maskPath); + + var normalExporter = new Mock(); + var handler = new GltfTextureHandler(normalExporter.Object, materialExporter.Object); + var asset = CreateTexturedAsset( + "textures/body_base_colour.dds", + "textures/body_mask.dds"); + var settings = new RmvToGltfExporterSettings( + asset.InputFile, + [], + outputPath, + true, + false, + false, + false, + false); + + var textures = handler.HandleTextures( + asset, + settings, + new GltfTextureExportSession(collisionSafe: false)); + + materialExporter.Verify( + x => x.Export( + It.Is(path => path.EndsWith("base_colour.dds", StringComparison.OrdinalIgnoreCase)), + It.IsAny(), + It.IsAny()), + Times.Once); + materialExporter.Verify( + x => x.Export( + It.Is(path => path.EndsWith("body_mask.dds", StringComparison.OrdinalIgnoreCase)), + It.IsAny(), + It.IsAny()), + Times.Once); + + Assert.That(textures, Has.Count.EqualTo(1)); + Assert.That(textures[0].GlftTexureType, Is.EqualTo(KnownChannel.BaseColor)); + Assert.That(textures[0].SystemFilePath, Is.EqualTo(baseColourPath)); + + var meshBuilder = new GltfMeshBuilder() + .Build(asset, textures, settings, willHaveSkeleton: false) + .Single(); + var model = ModelRoot.CreateModel(); + var scene = model.UseScene("default"); + var mesh = model.CreateMesh(meshBuilder); + scene.CreateNode("body").WithMesh(mesh); + + var baseColourChannel = model.LogicalMaterials.Single().FindChannel("BaseColor"); + Assert.That(baseColourChannel, Is.Not.Null); + var baseColourImage = baseColourChannel!.Value.Texture!.PrimaryImage!; + Assert.That(baseColourImage.Content.SourcePath, Does.EndWith("body_base_colour.png")); + Assert.That(baseColourImage.Content.SourcePath, Does.Not.EndWith("body_mask.png")); + + var staticModel = ModelRoot.CreateModel(); + staticModel.CreateMesh(new GltfStaticMeshBuilder().Build(asset, textures, settings).Single()); + Assert.That(staticModel.LogicalMaterials.Single().Alpha, Is.EqualTo(SharpGLTF.Schema2.AlphaMode.MASK)); + + model.Save(outputPath); + ModelRoot.Validate(outputPath); + var reloaded = ModelRoot.Load(outputPath); + var reloadedBaseColourChannel = reloaded.LogicalMaterials.Single().FindChannel("BaseColor"); + Assert.That(reloadedBaseColourChannel, Is.Not.Null); + Assert.That(reloadedBaseColourChannel!.Value.Texture, Is.Not.Null); + Assert.That(reloaded.LogicalImages, Has.Count.EqualTo(1)); + } + finally + { + if (Directory.Exists(outputDirectory)) + Directory.Delete(outputDirectory, recursive: true); + } + } + + private static ResolvedModelAsset CreateAsset(string firstTexture, string secondTexture) + { + var firstModel = CreateModel("first", firstTexture); + var secondModel = CreateModel("second", secondTexture); + var models = new[] { firstModel, secondModel }; + var header = new RmvFileHeader { Version = RmvVersionEnum.RMV2_V6, LodCount = 1 }; + header.SkeletonName = string.Empty; + var rmv = new RmvFile + { + Header = header, + ModelList = new[] { models }, + LodHeaders = new[] { LodHeaderFactory.Create().CreateEmpty(RmvVersionEnum.RMV2_V6, 0, 0, 0) } + }; + + var parts = new[] + { + new ResolvedModelPart(0, 0, firstModel, ResolvedModelMaterial.Create(firstModel.Material)), + new ResolvedModelPart(0, 1, secondModel, ResolvedModelMaterial.Create(secondModel.Material)) + }; + var input = PackFile.CreateFromASCII("model.rigid_model_v2", "model"); + return new ResolvedModelAsset(input, input, null, null, rmv, new[] { parts }, []); + } + + private static RmvModel CreateModel(string name, string texture) + { + var material = new WeightedMaterial { ModelName = name }; + material.SetTexture(TextureType.Diffuse, texture); + return new RmvModel + { + Material = material, + Mesh = new RmvMesh { VertexList = [], IndexList = [] } + }; + } + + private static ResolvedModelAsset CreateTexturedAsset(string baseColourPath, string maskPath) + { + var material = new WeightedMaterial { ModelName = "textured" }; + material.SetTexture(TextureType.BaseColour, baseColourPath); + material.SetTexture(TextureType.Mask, maskPath); + + var model = new RmvModel + { + Material = material, + Mesh = new RmvMesh + { + VertexList = + [ + CreateVertex(0, 0, 0), + CreateVertex(1, 0, 0), + CreateVertex(0, 1, 0) + ], + IndexList = [0, 1, 2] + } + }; + var header = new RmvFileHeader + { + Version = RmvVersionEnum.RMV2_V6, + LodCount = 1, + SkeletonName = string.Empty + }; + var rmv = new RmvFile + { + Header = header, + ModelList = [new[] { model }], + LodHeaders = [LodHeaderFactory.Create().CreateEmpty(RmvVersionEnum.RMV2_V6, 0, 0, 0)] + }; + var input = PackFile.CreateFromASCII("textured.rigid_model_v2", "textured"); + var part = new ResolvedModelPart(0, 0, model, ResolvedModelMaterial.Create(material)); + return new ResolvedModelAsset(input, input, null, null, rmv, [new[] { part }], []); + } + + private static CommonVertex CreateVertex(float x, float y, float z) + => new() + { + Position = new Microsoft.Xna.Framework.Vector4(x, y, z, 1), + Normal = Microsoft.Xna.Framework.Vector3.UnitZ, + Tangent = Microsoft.Xna.Framework.Vector3.UnitX, + Uv = Microsoft.Xna.Framework.Vector2.Zero, + BoneIndex = new byte[4], + BoneWeight = new float[4] + }; + + private static readonly byte[] OnePixelPng = Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="); +} diff --git a/Editors/ImportExportEditor/Test.ImportExport/Exporting/Exporters/RmvToGlft/RmvToGltfExporterTests.cs b/Editors/ImportExportEditor/Test.ImportExport/Exporting/Exporters/RmvToGlft/RmvToGltfExporterTests.cs index 45cfafd2b..4c7b3dce1 100644 --- a/Editors/ImportExportEditor/Test.ImportExport/Exporting/Exporters/RmvToGlft/RmvToGltfExporterTests.cs +++ b/Editors/ImportExportEditor/Test.ImportExport/Exporting/Exporters/RmvToGlft/RmvToGltfExporterTests.cs @@ -4,9 +4,12 @@ using Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers; using GameWorld.Core.Services; using Moq; +using System.IO; using Shared.Core.Events; +using Shared.GameFormats.RigidModel.Types; using Shared.TestUtility; using Test.TestingUtility.TestUtility; +using SharpGLTF.Schema2; namespace Test.ImportExport.Exporting.Exporters.RmvToGlft { @@ -15,6 +18,95 @@ public class RmvToGltfExporterTests { private readonly string _inputPackFileKarl = PathHelper.GetDataFolder("Data\\Karl_and_celestialgeneral_Pack"); private readonly string _rmvFilePathKarl = @"variantmeshes\wh_variantmodels\hu1\emp\emp_karl_franz\emp_karl_franz.rigid_model_v2"; + private readonly string _wsModelFilePathKarl = @"variantmeshes\wh_variantmodels\hu1\emp\emp_karl_franz\emp_karl_franz.wsmodel"; + private readonly string _inputPackFileRome = PathHelper.GetDataFolder("Data\\Rome_Man_And_Shield_Pack"); + + [Test] + public void ExportsTrackedVariantMeshDefinitionAsOneComposedScene() + { + var pfs = PackFileSerivceTestHelper.Create(_inputPackFileRome); + var definition = pfs.FindFile(@"variantmeshes\_variantmodels\man\shield\celtic_oval_patterns.variantmeshdefinition"); + Assert.That(definition, Is.Not.Null); + + var normalExporter = new Mock(); + var materialExporter = new Mock(); + var eventHub = new Mock(); + var skeletonLookup = new SkeletonAnimationLookUpHelper(pfs, eventHub.Object); + var compositionResolver = new VariantMeshCompositionResolver(pfs, new ModelAssetResolver(pfs)); + var sceneSaver = new TestGltfSceneSaver(); + var exporter = new RmvToGltfExporter( + sceneSaver, + new GltfMeshBuilder(), + new GltfTextureHandler(normalExporter.Object, materialExporter.Object, pfs), + new GltfSkeletonBuilder(pfs), + new GltfAnimationBuilder(pfs), + skeletonLookup, + new ModelAssetResolver(pfs), + compositionResolver); + + exporter.Export(new RmvToGltfExporterSettings( + definition!, + [], + Path.Combine(Path.GetTempPath(), "asset-editor-vmd.glb"), + false, + false, + false, + false, + false)); + + Assert.That(sceneSaver.IsSaveCalled, Is.True); + Assert.That(sceneSaver.ModelRoot, Is.Not.Null); + Assert.That(sceneSaver.ModelRoot!.LogicalMeshes, Is.Not.Empty); + Assert.That(sceneSaver.ModelRoot.LogicalNodes.Any(x => x.Name.StartsWith("vmd_part_", StringComparison.Ordinal)), Is.True); + } + + [Test] + public void ExportsTrackedVariantMeshDefinitionToReloadableGlb() + { + var pfs = PackFileSerivceTestHelper.Create(_inputPackFileRome); + var definition = pfs.FindFile(@"variantmeshes\_variantmodels\man\shield\celtic_oval_patterns.variantmeshdefinition"); + Assert.That(definition, Is.Not.Null); + + var outputPath = Path.Combine(Path.GetTempPath(), $"asset-editor-vmd-{Guid.NewGuid():N}.glb"); + var normalExporter = new Mock(); + var materialExporter = new Mock(); + var eventHub = new Mock(); + var skeletonLookup = new SkeletonAnimationLookUpHelper(pfs, eventHub.Object); + var compositionResolver = new VariantMeshCompositionResolver(pfs, new ModelAssetResolver(pfs)); + var exporter = new RmvToGltfExporter( + new FileGltfSceneSaver(), + new GltfMeshBuilder(), + new GltfTextureHandler(normalExporter.Object, materialExporter.Object, pfs), + new GltfSkeletonBuilder(pfs), + new GltfAnimationBuilder(pfs), + skeletonLookup, + new ModelAssetResolver(pfs), + compositionResolver); + + try + { + exporter.Export(new RmvToGltfExporterSettings( + definition!, + [], + outputPath, + false, + false, + false, + false, + false)); + + Assert.That(File.Exists(outputPath), Is.True); + ModelRoot.Validate(outputPath); + var reloaded = ModelRoot.Load(outputPath); + Assert.That(reloaded.LogicalMeshes, Is.Not.Empty); + Assert.That(reloaded.LogicalNodes.Any(x => x.Name.StartsWith("vmd_part_", StringComparison.Ordinal)), Is.True); + } + finally + { + if (File.Exists(outputPath)) + File.Delete(outputPath); + } + } [Test] public void Test() @@ -33,12 +125,20 @@ public void Test() // Act var mesh = pfs.FindFile(_rmvFilePathKarl); - var exporter = new RmvToGltfExporter(sceneSaver, meshBuilder, textureHandler, skeletontonBuilder, animationBuilder, skeletontonLookupHelper); - var settings = new RmvToGltfExporterSettings(mesh!, [], @"C:\test\myExport.gltf", true, true, true, true, true); + var exporter = new RmvToGltfExporter( + sceneSaver, + meshBuilder, + textureHandler, + skeletontonBuilder, + animationBuilder, + skeletontonLookupHelper, + new ModelAssetResolver(pfs)); + var settings = new RmvToGltfExporterSettings(mesh!, [], @"C:\test\myExport.glb", true, true, true, true, true); exporter.Export(settings); // Assert Assert.That(sceneSaver.IsSaveCalled, Is.True); + Assert.That(sceneSaver.FullSystemPath, Does.EndWith(".glb")); Assert.That(sceneSaver.ModelRoot, Is.Not.Null); Assert.That(sceneSaver.ModelRoot!.LogicalMaterials.Count(), Is.EqualTo(4)); @@ -53,5 +153,81 @@ public void Test() // Validate skeleton } + + [Test] + public void ResolvesExplicitKarlWsModelWithAllLod0Parts() + { + var pfs = PackFileSerivceTestHelper.Create(_inputPackFileKarl); + var wsModel = pfs.FindFile(_wsModelFilePathKarl); + Assert.That(wsModel, Is.Not.Null); + + var resolved = new ModelAssetResolver(pfs).Resolve(wsModel!); + + Assert.That(resolved.InputFile, Is.SameAs(wsModel)); + Assert.That(resolved.UsesWsModel, Is.True); + Assert.That(resolved.GeometryFile.Name, Does.EndWith("emp_karl_franz.rigid_model_v2")); + Assert.That(resolved.FirstLod, Has.Count.EqualTo(4)); + Assert.That(resolved.FirstLod.All(x => x.Material.UsesWsModelMaterial), Is.True); + } + + [Test] + public void ResolvesNestedSiblingWsModelFromRmvInputPath() + { + var pfs = PackFileSerivceTestHelper.Create(_inputPackFileKarl); + var rmv = pfs.FindFile(_rmvFilePathKarl); + Assert.That(rmv, Is.Not.Null); + + var resolved = new ModelAssetResolver(pfs).Resolve(rmv!); + + Assert.That(resolved.WsModelFile, Is.Not.Null); + Assert.That(pfs.GetFullPath(resolved.WsModelFile!), Does.EndWith("emp_karl_franz.wsmodel")); + Assert.That(resolved.FirstLod, Has.Count.EqualTo(4)); + Assert.That(resolved.FirstLod.All(x => x.Material.UsesWsModelMaterial), Is.True); + } + + [Test] + public void TextureExportUsesResolvedWsModelTexturePaths() + { + var pfs = PackFileSerivceTestHelper.Create(_inputPackFileKarl); + var wsModel = pfs.FindFile(_wsModelFilePathKarl)!; + var asset = new ModelAssetResolver(pfs).Resolve(wsModel); + var selectedTexture = asset.MaterialsByLod[0][1].GetTexture(TextureType.BaseColour); + Assert.That(selectedTexture, Is.EqualTo("VariantMeshes/wh_variantmodels/hu1/emp/emp_karl_franz/tex/emp_karl_franz_body_01_base_colour.dds")); + + var materialPaths = new List(); + var normalPaths = new List(); + var normalExporter = new Mock(); + normalExporter + .Setup(x => x.Export(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((path, _, _) => normalPaths.Add(path)) + .Returns((string)null!); + var materialExporter = new Mock(); + materialExporter + .Setup(x => x.Export(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((path, _, _) => materialPaths.Add(path)) + .Returns((string)null!); + + var handler = new GltfTextureHandler(normalExporter.Object, materialExporter.Object, pfs); + var settings = new RmvToGltfExporterSettings( + wsModel, + [], + Path.Combine(Path.GetTempPath(), "asset-editor-ws-test.glb"), + true, + false, + false, + false, + false); + + handler.HandleTextures(asset, settings); + + Assert.That(materialPaths, Does.Contain(selectedTexture)); + Assert.That(normalPaths, Does.Contain("VariantMeshes/wh_variantmodels/hu1/emp/emp_karl_franz/tex/emp_karl_franz_body_01_normal.dds")); + } + } + + internal sealed class FileGltfSceneSaver : IGltfSceneSaver + { + public void Save(ModelRoot modelRoot, string fullSystemPath) + => modelRoot.Save(fullSystemPath); } } diff --git a/Editors/ImportExportEditor/Test.ImportExport/Exporting/Exporters/RmvToGlft/VmdSkeletonExportTests.cs b/Editors/ImportExportEditor/Test.ImportExport/Exporting/Exporters/RmvToGlft/VmdSkeletonExportTests.cs new file mode 100644 index 000000000..99de429ba --- /dev/null +++ b/Editors/ImportExportEditor/Test.ImportExport/Exporting/Exporters/RmvToGlft/VmdSkeletonExportTests.cs @@ -0,0 +1,255 @@ +using System.Numerics; +using Editors.ImportExport.Exporting.Exporters.RmvToGltf; +using Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers; +using GameWorld.Core.Services; +using Moq; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.GameFormats.Animation; +using Shared.GameFormats.RigidModel; +using Shared.GameFormats.RigidModel.LodHeader; +using Shared.GameFormats.RigidModel.MaterialHeaders; +using Shared.GameFormats.RigidModel.Transforms; +using Shared.GameFormats.RigidModel.Types; +using Shared.GameFormats.RigidModel.Vertex; + +namespace Test.ImportExport.Exporting.Exporters.RmvToGlft; + +public class VmdSkeletonExportTests +{ + [Test] + public void VmdExportsSkeletonWithoutAnimationsAndBuildsSelectedAnimationsOnce() + { + var input = PackFile.CreateFromASCII("root.variantmeshdefinition", ""); + var asset = CreateAsset(input); + var root = new ResolvedVariantMeshNode("root", input, null) { ModelAsset = asset }; + var secondInput = PackFile.CreateFromASCII("second.rigid_model_v2", "second"); + root.Slots.Add(new ResolvedVariantMeshSlot("second", string.Empty) + { + SelectedChild = new ResolvedVariantMeshNode("second", secondInput, null) + { + ModelAsset = CreateAsset(secondInput, "second") + } + }); + var composition = new ResolvedVariantMeshComposition(input, root, []); + var compositionResolver = new Mock(); + compositionResolver.Setup(x => x.Resolve(input)).Returns(composition); + + var packFileService = new Mock().Object; + var skeletonFile = CreateSkeletonFile(); + var skeletonLookup = new Mock(); + skeletonLookup + .Setup(x => x.GetSkeletonFileFromName("test_skeleton")) + .Returns(skeletonFile); + var animationBuilder = new CountingAnimationBuilder(packFileService); + var saver = new TestGltfSceneSaver(); + var exporter = new RmvToGltfExporter( + saver, + new GltfMeshBuilder(), + new EmptyTextureHandler(), + new GltfSkeletonBuilder(packFileService), + animationBuilder, + skeletonLookup.Object, + new Mock().Object, + compositionResolver.Object); + + exporter.Export(new RmvToGltfExporterSettings( + input, + [], + Path.Combine(Path.GetTempPath(), "vmd-skeleton-only.gltf"), + false, + false, + false, + false, + false)); + + Assert.That(saver.ModelRoot, Is.Not.Null); + Assert.That(saver.ModelRoot!.LogicalNodes.Any(x => x.Name == "root"), Is.True); + Assert.That( + saver.ModelRoot.LogicalNodes.Any(x => x.Name == "//skeleton//test_skeleton"), + Is.True, + "A composed VMD export keeps its shared skeleton even when animation clips are disabled."); + Assert.That(saver.ModelRoot.LogicalSkins, Has.Count.EqualTo(0)); + Assert.That(saver.ModelRoot.LogicalAnimations, Has.Count.EqualTo(0)); + + var selectedAnimation = PackFile.CreateFromASCII("idle.anim", "not parsed by the counting test builder"); + exporter.Export(new RmvToGltfExporterSettings( + input, + [selectedAnimation], + Path.Combine(Path.GetTempPath(), "vmd-with-animation.gltf"), + false, + false, + false, + true, + false)); + + Assert.That(animationBuilder.BuildCount, Is.EqualTo(1), + "A composed export has one shared skeleton and one animation-build pass regardless of part count."); + } + + [Test] + public void DoesNotSkinComponentAgainstDifferentNamedSharedSkeleton() + { + var rootInput = PackFile.CreateFromASCII("root.variantmeshdefinition", ""); + var childInput = PackFile.CreateFromASCII("child.rigid_model_v2", "child"); + var root = new ResolvedVariantMeshNode("root", rootInput, null) + { + ModelAsset = CreateAsset(rootInput, "root", "test_skeleton", weighted: true) + }; + root.Slots.Add(new ResolvedVariantMeshSlot("child", string.Empty) + { + SelectedChild = new ResolvedVariantMeshNode("child", childInput, null) + { + ModelAsset = CreateAsset(childInput, "child", "other_skeleton", weighted: true, matrixIndex: 0) + } + }); + + var composition = new ResolvedVariantMeshComposition(rootInput, root, []); + var compositionResolver = new Mock(); + compositionResolver.Setup(x => x.Resolve(rootInput)).Returns(composition); + + var packFileService = new Mock().Object; + var skeletonLookup = new Mock(); + skeletonLookup + .Setup(x => x.GetSkeletonFileFromName("test_skeleton")) + .Returns(CreateSkeletonFile()); + var saver = new TestGltfSceneSaver(); + var exporter = new RmvToGltfExporter( + saver, + new GltfMeshBuilder(), + new EmptyTextureHandler(), + new GltfSkeletonBuilder(packFileService), + new CountingAnimationBuilder(packFileService), + skeletonLookup.Object, + new Mock().Object, + compositionResolver.Object); + + exporter.Export(new RmvToGltfExporterSettings( + rootInput, + [], + Path.Combine(Path.GetTempPath(), "vmd-different-skeleton.gltf"), + false, + false, + false, + false, + false)); + + Assert.That(saver.ModelRoot, Is.Not.Null); + var rootNode = saver.ModelRoot!.LogicalNodes.Single(x => x.Name == "vmd_part_000_000_root"); + var childNode = saver.ModelRoot.LogicalNodes.Single(x => x.Name == "vmd_part_001_000_child"); + Assert.That(rootNode.Skin, Is.Not.Null); + Assert.That(childNode.Skin, Is.Null, + "A component declaring another skeleton must remain unskinned rather than binding to the shared root skeleton."); + Assert.That(childNode.VisualParent, Is.Null, + "A differing component skeleton must not interpret its MatrixIndex against the shared root skeleton."); + Assert.That(saver.ModelRoot.LogicalSkins, Has.Count.EqualTo(1)); + } + + private static ResolvedModelAsset CreateAsset( + PackFile input, + string modelName = "synthetic", + string skeletonName = "test_skeleton", + bool weighted = false, + int matrixIndex = -1) + { + var material = new WeightedMaterial { ModelName = modelName, MatrixIndex = matrixIndex }; + var model = new RmvModel + { + Material = material, + Mesh = new RmvMesh + { + VertexList = + [ + CreateVertex(new Vector3(0, 0, 0), weighted), + CreateVertex(new Vector3(1, 0, 0), weighted), + CreateVertex(new Vector3(0, 1, 0), weighted) + ], + IndexList = [0, 1, 2] + } + }; + var header = new RmvFileHeader + { + Version = RmvVersionEnum.RMV2_V6, + LodCount = 1, + SkeletonName = skeletonName + }; + var rmv = new RmvFile + { + Header = header, + ModelList = [new[] { model }], + LodHeaders = [LodHeaderFactory.Create().CreateEmpty(RmvVersionEnum.RMV2_V6, 0, 0, 0)] + }; + var part = new ResolvedModelPart(0, 0, model, ResolvedModelMaterial.Create(material)); + return new ResolvedModelAsset(input, input, null, null, rmv, [new[] { part }], []); + } + + private static CommonVertex CreateVertex(Vector3 position, bool weighted = false) + { + var vertex = new CommonVertex + { + Position = new Microsoft.Xna.Framework.Vector4(position.X, position.Y, position.Z, 1), + Normal = Microsoft.Xna.Framework.Vector3.UnitZ, + Tangent = Microsoft.Xna.Framework.Vector3.UnitX, + Uv = Microsoft.Xna.Framework.Vector2.Zero, + BoneIndex = new byte[4], + BoneWeight = new float[4] + }; + + if (weighted) + { + vertex.WeightCount = 1; + vertex.BoneIndex[0] = 0; + vertex.BoneWeight[0] = 1; + } + + return vertex; + } + + private static AnimationFile CreateSkeletonFile() + { + var file = new AnimationFile(); + file.Header.SkeletonName = "test_skeleton"; + file.Bones = [new AnimationFile.BoneInfo { Id = 0, Name = "root", ParentId = AnimationFile.BoneIndexNoParent }]; + var frame = new AnimationFile.Frame(); + frame.Transforms.Add(new RmvVector3(Microsoft.Xna.Framework.Vector3.Zero)); + frame.Quaternion.Add(new RmvVector4(0, 0, 0, 1)); + var part = new AnimationFile.AnimationPart(); + part.DynamicFrames.Add(frame); + file.AnimationParts.Add(part); + return file; + } + + private sealed class EmptyTextureHandler : IGltfTextureHandler + { + public List HandleTextures(RmvFile rmvFile, RmvToGltfExporterSettings settings) + => []; + + public List HandleTextures(ResolvedModelAsset asset, RmvToGltfExporterSettings settings) + => []; + + public List HandleTextures( + ResolvedModelAsset asset, + RmvToGltfExporterSettings settings, + GltfTextureExportSession session) + => []; + } + + private sealed class CountingAnimationBuilder : GltfAnimationBuilder + { + public CountingAnimationBuilder(IPackFileService packFileService) + : base(packFileService) + { + } + + public int BuildCount { get; private set; } + + public override void Build( + AnimationFile animSkeleton, + RmvToGltfExporterSettings settings, + ProcessedGltfSkeleton gltfSkeleton, + SharpGLTF.Schema2.ModelRoot outputScene) + { + BuildCount++; + } + } +} diff --git a/Editors/Kitbashing/KitbasherEditor/Services/KitbashSceneCreator.cs b/Editors/Kitbashing/KitbasherEditor/Services/KitbashSceneCreator.cs index c09add6cc..79a2ea33b 100644 --- a/Editors/Kitbashing/KitbasherEditor/Services/KitbashSceneCreator.cs +++ b/Editors/Kitbashing/KitbasherEditor/Services/KitbashSceneCreator.cs @@ -25,6 +25,7 @@ public class KitbashSceneCreator private readonly ComplexMeshLoader _complexMeshLoader; private readonly SceneManager _sceneManager; private readonly Rmv2ModelNodeLoader _rmv2ModelNodeLoader; + private readonly IModelAssetResolver _modelAssetResolver; private readonly GeometrySaveSettings _saveSettings; public KitbashSceneCreator( @@ -35,7 +36,8 @@ public KitbashSceneCreator( IPackFileService packFileService, Rmv2ModelNodeLoader rmv2ModelNodeLoader, GeometrySaveSettings saveSettings, - IScopedLogger scopedLogger) + IScopedLogger scopedLogger, + IModelAssetResolver? modelAssetResolver = null) { _logger = scopedLogger.ForContext(); _packFileService = packFileService; @@ -45,6 +47,7 @@ public KitbashSceneCreator( _sceneManager = sceneManager; _rmv2ModelNodeLoader = rmv2ModelNodeLoader; _saveSettings = saveSettings; + _modelAssetResolver = modelAssetResolver ?? new ModelAssetResolver(packFileService); } public void CreateFromPackFile(PackFile file) @@ -69,20 +72,10 @@ public void CreateFromPackFile(PackFile file) return; } - WsModelFile? wsModel = null; - RmvFile rmv; - if (extension == ".wsmodel") - { - wsModel = new WsModelFile(file); - var rmvPackFile = _packFileService.FindFile(wsModel.GeometryPath); - rmv = ModelFactory.Create().Load(rmvPackFile.DataSource.ReadData()); - } - else - { - rmv = ModelFactory.Create().Load(file.DataSource.ReadData()); - } - - var lodNodes = _rmv2ModelNodeLoader.CreateModelNodesFromFile(rmv, modelFullPath, false, wsModel); + var resolvedAsset = _modelAssetResolver.Resolve(file); + var rmv = resolvedAsset.Model; + var geometryFullPath = _packFileService.GetFullPath(resolvedAsset.GeometryFile); + var lodNodes = _rmv2ModelNodeLoader.CreateModelNodesFromAsset(resolvedAsset, geometryFullPath, false); foreach (var lodNode in lodNodes) { SceneNodeHelper diff --git a/GameWorld/GameWorldCore/GameWorld.Core/DependencyInjectionContainer.cs b/GameWorld/GameWorldCore/GameWorld.Core/DependencyInjectionContainer.cs index 92cc4715b..a93026a7f 100644 --- a/GameWorld/GameWorldCore/GameWorld.Core/DependencyInjectionContainer.cs +++ b/GameWorld/GameWorldCore/GameWorld.Core/DependencyInjectionContainer.cs @@ -56,6 +56,8 @@ public override void Register(IServiceCollection serviceCollection) serviceCollection.AddScoped(); serviceCollection.AddScoped(); serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); serviceCollection.AddTransient(); serviceCollection.AddTransient(); serviceCollection.AddScoped(); diff --git a/GameWorld/GameWorldCore/GameWorld.Core/SceneNodes/Rmv2ModelNodeLoader.cs b/GameWorld/GameWorldCore/GameWorld.Core/SceneNodes/Rmv2ModelNodeLoader.cs index 4b87bacd4..41f71bdc8 100644 --- a/GameWorld/GameWorldCore/GameWorld.Core/SceneNodes/Rmv2ModelNodeLoader.cs +++ b/GameWorld/GameWorldCore/GameWorld.Core/SceneNodes/Rmv2ModelNodeLoader.cs @@ -13,30 +13,43 @@ public class Rmv2ModelNodeLoader { private readonly ILogger _logger; private readonly MeshBuilderService _meshBuilderService; - private readonly IPackFileService _packFileService; private readonly CapabilityMaterialFactory _capabilityMaterialFactory; - private readonly IStandardDialogs _standardDialogs; + private readonly IModelAssetResolver _modelAssetResolver; - public Rmv2ModelNodeLoader(MeshBuilderService meshBuilderService, IPackFileService packFileService, CapabilityMaterialFactory materialFactory, IStandardDialogs exceptionService, IScopedLogger scopedLogger) + public Rmv2ModelNodeLoader( + MeshBuilderService meshBuilderService, + IPackFileService packFileService, + CapabilityMaterialFactory materialFactory, + IStandardDialogs exceptionService, + IScopedLogger scopedLogger, + IModelAssetResolver? modelAssetResolver = null) { _logger = scopedLogger.ForContext(); _meshBuilderService = meshBuilderService; - _packFileService = packFileService; _capabilityMaterialFactory = materialFactory; - _standardDialogs = exceptionService; + _modelAssetResolver = modelAssetResolver ?? new ModelAssetResolver(packFileService); } public List CreateModelNodesFromFile(RmvFile model, string modelFullPath, bool onlyLoadRootNode, WsModelFile? wsModel = null) { - WsModelMaterialProvider wsMaterialProvider; - if(wsModel != null) - wsMaterialProvider = WsModelMaterialProvider.CreateFromWsModel(_packFileService, _capabilityMaterialFactory, _standardDialogs, wsModel); - else - wsMaterialProvider = WsModelMaterialProvider.CreateFromModelPath(_packFileService, _capabilityMaterialFactory, _standardDialogs, modelFullPath); + var resolvedMaterials = _modelAssetResolver.ResolveMaterials(model, wsModel, modelFullPath); + LogResolutionDiagnostics(resolvedMaterials.Diagnostics); + return CreateModelNodes(model, modelFullPath, onlyLoadRootNode, resolvedMaterials.PartsByLod); + } - var meshCountInLods = model.ModelList.Select(x => x.Count()).ToArray(); - wsMaterialProvider.ValidateWsModelMaterial(meshCountInLods); + public List CreateModelNodesFromAsset(ResolvedModelAsset asset, string modelFullPath, bool onlyLoadRootNode) + { + ArgumentNullException.ThrowIfNull(asset); + LogResolutionDiagnostics(asset.Diagnostics); + return CreateModelNodes(asset.Model, modelFullPath, onlyLoadRootNode, asset.PartsByLod); + } + private List CreateModelNodes( + RmvFile model, + string modelFullPath, + bool onlyLoadRootNode, + IReadOnlyList> resolvedPartsByLod) + { var output = new List(); for (var lodIndex = 0; lodIndex < model.Header.LodCount; lodIndex++) { @@ -48,7 +61,12 @@ public List CreateModelNodesFromFile(RmvFile model, string modelFul var rmvModel = model.ModelList[lodIndex][modelIndex]; var geometry = _meshBuilderService.BuildMeshFromRmvModel(rmvModel, model.Header.SkeletonName); - var shader = wsMaterialProvider.ConstructMaterial(lodIndex, modelIndex, rmvModel.Material); + var resolvedPart = resolvedPartsByLod.Count > lodIndex && resolvedPartsByLod[lodIndex].Count > modelIndex + ? resolvedPartsByLod[lodIndex][modelIndex] + : null; + var shader = resolvedPart == null + ? _capabilityMaterialFactory.Create(rmvModel.Material) + : _capabilityMaterialFactory.Create(resolvedPart.Material.SourceMaterial, resolvedPart.Material.WsModelMaterial); // This if statement is for Pharaoh Total War, the base game models do not have a model name by default so I am grabbing it // from the model file path. @@ -68,7 +86,12 @@ public List CreateModelNodesFromFile(RmvFile model, string modelFul return output; } + + private void LogResolutionDiagnostics(IReadOnlyList diagnostics) + { + foreach (var diagnostic in diagnostics) + _logger.Here().Warning(diagnostic); + } } } - diff --git a/GameWorld/GameWorldCore/GameWorld.Core/Services/ComplexMeshLoader.cs b/GameWorld/GameWorldCore/GameWorld.Core/Services/ComplexMeshLoader.cs index 48fe720a0..5ff9ab944 100644 --- a/GameWorld/GameWorldCore/GameWorld.Core/Services/ComplexMeshLoader.cs +++ b/GameWorld/GameWorldCore/GameWorld.Core/Services/ComplexMeshLoader.cs @@ -4,9 +4,7 @@ using Shared.Core.ErrorHandling; using Shared.Core.PackFiles; using Shared.Core.PackFiles.Models; -using Shared.GameFormats.RigidModel; using Shared.GameFormats.Vmd; -using Shared.GameFormats.WsModel; using static Shared.GameFormats.Vmd.VariantMeshDefinition; namespace GameWorld.Core.Services @@ -16,15 +14,21 @@ public class ComplexMeshLoader private readonly ILogger _logger; private readonly IPackFileService _packFileService; private readonly Rmv2ModelNodeLoader _rmv2ModelNodeLoader; + private readonly IModelAssetResolver _modelAssetResolver; - public ComplexMeshLoader(Rmv2ModelNodeLoader rmv2ModelNodeLoader, IPackFileService packFileService, IScopedLogger scopedLogger) + public ComplexMeshLoader( + Rmv2ModelNodeLoader rmv2ModelNodeLoader, + IPackFileService packFileService, + IScopedLogger scopedLogger, + IModelAssetResolver? modelAssetResolver = null) { _logger = scopedLogger.ForContext(); _packFileService = packFileService; _rmv2ModelNodeLoader = rmv2ModelNodeLoader; + _modelAssetResolver = modelAssetResolver ?? new ModelAssetResolver(packFileService); } - public SceneNode Load(PackFile file, SceneNode parent, AnimationPlayer player, bool onlyLoadRootNode, bool onlyLoadFirstMesh) + public SceneNode Load(PackFile file, SceneNode? parent, AnimationPlayer player, bool onlyLoadRootNode, bool onlyLoadFirstMesh) { return Load(file, parent, player, null, onlyLoadRootNode, onlyLoadFirstMesh); } @@ -34,7 +38,7 @@ public SceneNode Load(PackFile file, AnimationPlayer player, bool onlyLoadRootNo return Load(file, null, player, null, onlyLoadRootNode, onlyLoadFirstMesh); } - SceneNode Load(PackFile file, SceneNode parent, AnimationPlayer player, string attachmentPointName, bool onlyLoadRootNode, bool onlyLoadFirstMesh) + SceneNode Load(PackFile file, SceneNode? parent, AnimationPlayer player, string? attachmentPointName, bool onlyLoadRootNode, bool onlyLoadFirstMesh) { try { @@ -60,7 +64,7 @@ SceneNode Load(PackFile file, SceneNode parent, AnimationPlayer player, string a throw new Exception("Unknown mesh extention"); } - return parent; + return parent!; } catch (Exception e) { @@ -73,7 +77,7 @@ SceneNode Load(PackFile file, SceneNode parent, AnimationPlayer player, string a } } - void Load(string path, SceneNode parent, AnimationPlayer player, string attachmentPointName, bool onlyLoadRootNode, bool onlyLoadFirstMesh) + void Load(string path, SceneNode parent, AnimationPlayer player, string? attachmentPointName, bool onlyLoadRootNode, bool onlyLoadFirstMesh) { var file = _packFileService.FindFile(path); if (file == null) @@ -86,7 +90,7 @@ void Load(string path, SceneNode parent, AnimationPlayer player, string attachme } - void LoadVariantMesh(PackFile file, ref SceneNode parent, AnimationPlayer player, string attachmentPointName, bool onlyLoadRootNode, bool onlyLoadFirstMesh) + void LoadVariantMesh(PackFile file, ref SceneNode? parent, AnimationPlayer player, string? attachmentPointName, bool onlyLoadRootNode, bool onlyLoadFirstMesh) { var variantMeshElement = new VariantMeshNode(file.Name); if (parent == null) @@ -99,7 +103,7 @@ void LoadVariantMesh(PackFile file, ref SceneNode parent, AnimationPlayer player LoadVariantMesh(meshFile, variantMeshElement, player, attachmentPointName, onlyLoadRootNode, onlyLoadFirstMesh); } - void LoadVariantMesh(VariantMesh mesh, SceneNode root, AnimationPlayer player, string attachmentPointName, bool onlyLoadRootNode, bool onlyLoadFirstMesh) + void LoadVariantMesh(VariantMesh mesh, SceneNode root, AnimationPlayer player, string? attachmentPointName, bool onlyLoadRootNode, bool onlyLoadFirstMesh) { if (mesh.ChildSlots.Count != 0) root = root.AddObject(new SlotsNode("Slots")); @@ -151,13 +155,17 @@ void LoadVariantMesh(VariantMesh mesh, SceneNode root, AnimationPlayer player, s } } - Rmv2ModelNode LoadRigidMesh(PackFile file, ref SceneNode parent, AnimationPlayer player, string attachmentPointName, bool onlyLoadRootNode, WsModelFile? wsModel = null) + Rmv2ModelNode LoadRigidMesh(PackFile file, ref SceneNode? parent, AnimationPlayer player, string? attachmentPointName, bool onlyLoadRootNode) { - var rmvModel = ModelFactory.Create().Load(file.DataSource.ReadData()); + var asset = _modelAssetResolver.Resolve(file); + return LoadRigidMesh(asset, ref parent, player, attachmentPointName, onlyLoadRootNode); + } - var modelFullPath = _packFileService.GetFullPath(file); - var modelNode = new Rmv2ModelNode(Path.GetFileName(file.Name)); - var lodNodes = _rmv2ModelNodeLoader.CreateModelNodesFromFile(rmvModel, modelFullPath, onlyLoadRootNode, wsModel); + Rmv2ModelNode LoadRigidMesh(ResolvedModelAsset asset, ref SceneNode? parent, AnimationPlayer player, string? attachmentPointName, bool onlyLoadRootNode) + { + var modelFullPath = _packFileService.GetFullPath(asset.GeometryFile); + var modelNode = new Rmv2ModelNode(Path.GetFileName(asset.GeometryFile.Name)); + var lodNodes = _rmv2ModelNodeLoader.CreateModelNodesFromAsset(asset, modelFullPath, onlyLoadRootNode); foreach (var lodNode in lodNodes) { SceneNodeHelper @@ -169,7 +177,7 @@ Rmv2ModelNode LoadRigidMesh(PackFile file, ref SceneNode parent, AnimationPlayer foreach (var mesh in modelNode.GetMeshNodes(0)) - mesh.AttachmentPointName = attachmentPointName; + mesh.AttachmentPointName = attachmentPointName ?? string.Empty; if (parent == null) parent = modelNode; @@ -179,7 +187,7 @@ Rmv2ModelNode LoadRigidMesh(PackFile file, ref SceneNode parent, AnimationPlayer return modelNode; } - void LoadWsModel(PackFile file, ref SceneNode parent, AnimationPlayer player, string attachmentPointName, bool onlyLoadRootNode) + void LoadWsModel(PackFile file, ref SceneNode? parent, AnimationPlayer player, string? attachmentPointName, bool onlyLoadRootNode) { var wsModelNode = new WsModelGroup("WsModel - " + file.Name); if (parent == null) @@ -187,13 +195,8 @@ void LoadWsModel(PackFile file, ref SceneNode parent, AnimationPlayer player, st else parent.AddObject(wsModelNode); - var wsMaterial = new WsModelFile(file); - if (string.IsNullOrWhiteSpace(wsMaterial.GeometryPath) == false) - { - var modelFile = _packFileService.FindFile(wsMaterial.GeometryPath); - var modelAsBase = wsModelNode as SceneNode; - var loadedModelNode = LoadRigidMesh(modelFile, ref modelAsBase, player, attachmentPointName, onlyLoadRootNode, wsMaterial); - } + var modelAsBase = wsModelNode as SceneNode; + _ = LoadRigidMesh(_modelAssetResolver.Resolve(file), ref modelAsBase, player, attachmentPointName, onlyLoadRootNode); } } } diff --git a/GameWorld/GameWorldCore/GameWorld.Core/Services/ModelAssetResolver.cs b/GameWorld/GameWorldCore/GameWorld.Core/Services/ModelAssetResolver.cs new file mode 100644 index 000000000..b49f92cde --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.Core/Services/ModelAssetResolver.cs @@ -0,0 +1,413 @@ +using System.Collections.ObjectModel; +using System.IO; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.GameFormats.RigidModel; +using Shared.GameFormats.RigidModel.MaterialHeaders; +using Shared.GameFormats.RigidModel.Types; +using Shared.GameFormats.WsModel; + +namespace GameWorld.Core.Services; + +/// +/// Resolves a model input to the geometry and effective material data used by +/// AssetEditor's model views. This type deliberately contains no renderer or +/// UI objects so it can also be used by import/export code. +/// +public interface IModelAssetResolver +{ + ResolvedModelAsset Resolve(PackFile inputFile); + + /// + /// Resolves the effective material for an already parsed RMV2 model. This + /// keeps the legacy scene-loader entry point on the same interpretation as + /// . + /// + ResolvedModelMaterials ResolveMaterials(RmvFile model, WsModelFile? wsModel = null, string? modelPath = null); +} + +public sealed class ModelAssetResolver : IModelAssetResolver +{ + private readonly IPackFileService? _packFileService; + + public ModelAssetResolver(IPackFileService? packFileService = null) + { + _packFileService = packFileService; + } + + public ResolvedModelAsset Resolve(PackFile inputFile) + { + ArgumentNullException.ThrowIfNull(inputFile); + + var diagnostics = new List(); + PackFile geometryFile; + WsModelFile? wsModel = null; + PackFile? wsModelFile = null; + + if (IsRmvFile(inputFile.Name)) + { + geometryFile = inputFile; + wsModelFile = FindSiblingWsModel(inputFile); + if (wsModelFile != null) + { + try + { + wsModel = new WsModelFile(wsModelFile); + } + catch (Exception exception) + { + diagnostics.Add($"Unable to read sibling WSModel '{wsModelFile.Name}'; using RMV2 materials. {exception.Message}"); + wsModelFile = null; + } + } + } + else if (IsWsModelFile(inputFile.Name)) + { + wsModelFile = inputFile; + WsModelFile parsedWsModel; + try + { + parsedWsModel = new WsModelFile(inputFile); + } + catch (Exception exception) + { + throw new ModelAssetResolutionException($"Unable to read WSModel '{inputFile.Name}'.", exception); + } + + wsModel = parsedWsModel; + if (string.IsNullOrWhiteSpace(parsedWsModel.GeometryPath)) + throw new ModelAssetResolutionException($"WSModel '{inputFile.Name}' does not specify a geometry file."); + + geometryFile = FindFile(parsedWsModel.GeometryPath) + ?? throw new ModelAssetResolutionException($"WSModel '{inputFile.Name}' references missing geometry '{parsedWsModel.GeometryPath}'."); + } + else + { + throw new ModelAssetResolutionException($"Unsupported model input '{inputFile.Name}'. Expected a .rigid_model_v2 or .wsmodel file."); + } + + RmvFile rmvFile; + try + { + rmvFile = ModelFactory.Create().Load(geometryFile.DataSource.ReadData()); + } + catch (Exception exception) + { + throw new ModelAssetResolutionException($"Unable to read geometry '{geometryFile.Name}'.", exception); + } + + var effectiveMaterials = ResolveMaterials(rmvFile, wsModel, wsModelFile?.Name); + diagnostics.AddRange(effectiveMaterials.Diagnostics); + return new ResolvedModelAsset( + inputFile, + geometryFile, + wsModelFile, + wsModel, + rmvFile, + effectiveMaterials.PartsByLod, + diagnostics); + } + + public ResolvedModelMaterials ResolveMaterials( + RmvFile rmvFile, + WsModelFile? wsModel = null, + string? modelPath = null) + { + ArgumentNullException.ThrowIfNull(rmvFile); + + var diagnostics = new List(); + var resolvedWsModel = wsModel; + var resolvedWsModelPath = modelPath; + + // The legacy loader receives a parsed RMV2 model and its full pack + // path rather than the source PackFile. Discover the sibling through + // the pack service so nested models keep their directory context. + if (resolvedWsModel == null + && _packFileService != null + && string.IsNullOrWhiteSpace(modelPath) == false + && IsRmvFile(modelPath)) + { + var siblingPath = Path.ChangeExtension(modelPath, ".wsmodel"); + var siblingFile = FindFile(siblingPath); + if (siblingFile != null) + { + try + { + resolvedWsModel = new WsModelFile(siblingFile); + resolvedWsModelPath = siblingFile.Name; + } + catch (Exception exception) + { + diagnostics.Add($"Unable to read sibling WSModel '{siblingFile.Name}'; using RMV2 materials. {exception.Message}"); + } + } + } + + var partsByLod = ResolveMaterials(rmvFile, resolvedWsModel, resolvedWsModelPath, diagnostics); + return new ResolvedModelMaterials(partsByLod, diagnostics); + } + + private List> ResolveMaterials( + RmvFile rmvFile, + WsModelFile? wsModel, + string? wsModelPath, + List diagnostics) + { + var output = new List>(rmvFile.ModelList.Length); + var useWsModel = wsModel != null && HasCompleteMaterialMapping(rmvFile, wsModel, wsModelPath, diagnostics); + + for (var lodIndex = 0; lodIndex < rmvFile.ModelList.Length; lodIndex++) + { + var parts = new List(rmvFile.ModelList[lodIndex].Length); + for (var partIndex = 0; partIndex < rmvFile.ModelList[lodIndex].Length; partIndex++) + { + var rmvModel = rmvFile.ModelList[lodIndex][partIndex]; + WsModelMaterialFile? wsMaterial = null; + string? wsMaterialPath = null; + + if (useWsModel) + { + var mapping = wsModel!.MaterialList.First(x => x.LodIndex == lodIndex && x.PartIndex == partIndex); + wsMaterialPath = mapping.MaterialPath; + var materialFile = FindFile(mapping.MaterialPath); + if (materialFile == null) + { + diagnostics.Add($"WSModel material '{mapping.MaterialPath}' for LOD {lodIndex}, part {partIndex} was not found; using RMV2 material."); + } + else + { + try + { + wsMaterial = new WsModelMaterialFile(materialFile); + } + catch (Exception exception) + { + diagnostics.Add($"Unable to read WSModel material '{mapping.MaterialPath}' for LOD {lodIndex}, part {partIndex}; using RMV2 material. {exception.Message}"); + } + } + } + + var effectiveMaterial = ResolvedModelMaterial.Create(rmvModel.Material, wsMaterial, wsMaterialPath); + parts.Add(new ResolvedModelPart(lodIndex, partIndex, rmvModel, effectiveMaterial)); + } + + output.Add(new ReadOnlyCollection(parts)); + } + + return output; + } + + private bool HasCompleteMaterialMapping( + RmvFile rmvFile, + WsModelFile wsModel, + string? wsModelPath, + List diagnostics) + { + var expected = new HashSet<(int LodIndex, int PartIndex)>(); + for (var lodIndex = 0; lodIndex < rmvFile.ModelList.Length; lodIndex++) + { + for (var partIndex = 0; partIndex < rmvFile.ModelList[lodIndex].Length; partIndex++) + expected.Add((lodIndex, partIndex)); + } + + var actual = new HashSet<(int LodIndex, int PartIndex)>(); + foreach (var entry in wsModel.MaterialList) + { + if (expected.Contains((entry.LodIndex, entry.PartIndex)) == false) + { + // The viewport ignores mappings for geometry that is not + // present in the loaded RMV2. Keep the same behavior here. + continue; + } + + if (actual.Add((entry.LodIndex, entry.PartIndex)) == false) + { + diagnostics.Add($"WSModel '{wsModelPath ?? ""}' contains duplicate material mappings; using RMV2 materials."); + return false; + } + } + + if (actual.SetEquals(expected)) + return true; + + diagnostics.Add($"WSModel '{wsModelPath ?? ""}' does not contain one material mapping for every RMV2 LOD/part; using RMV2 materials."); + return false; + } + + private PackFile? FindSiblingWsModel(PackFile rmvFile) + { + if (_packFileService == null) + return null; + + var rmvPath = _packFileService.GetFullPath(rmvFile); + var siblingPath = Path.ChangeExtension(rmvPath, ".wsmodel"); + return FindFile(siblingPath); + } + + private PackFile? FindFile(string path) + { + return _packFileService?.FindFile(path); + } + + private static bool IsRmvFile(string path) => path.EndsWith(".rigid_model_v2", StringComparison.OrdinalIgnoreCase); + + private static bool IsWsModelFile(string path) => path.EndsWith(".wsmodel", StringComparison.OrdinalIgnoreCase); +} + +public sealed class ModelAssetResolutionException : Exception +{ + public ModelAssetResolutionException(string message) + : base(message) + { + } + + public ModelAssetResolutionException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +public sealed class ResolvedModelMaterials +{ + public ResolvedModelMaterials( + IReadOnlyList> partsByLod, + IReadOnlyList diagnostics) + { + PartsByLod = partsByLod; + MaterialsByLod = partsByLod + .Select(parts => (IReadOnlyList)new ReadOnlyCollection(parts.Select(x => x.Material).ToList())) + .ToList(); + Diagnostics = diagnostics; + } + + public IReadOnlyList> PartsByLod { get; } + public IReadOnlyList> MaterialsByLod { get; } + public IReadOnlyList Diagnostics { get; } +} + +public sealed class ResolvedModelAsset +{ + public ResolvedModelAsset( + PackFile inputFile, + PackFile geometryFile, + PackFile? wsModelFile, + WsModelFile? wsModel, + RmvFile model, + IReadOnlyList> partsByLod, + IReadOnlyList diagnostics) + { + InputFile = inputFile; + GeometryFile = geometryFile; + WsModelFile = wsModelFile; + WsModel = wsModel; + Model = model; + PartsByLod = partsByLod; + MaterialsByLod = partsByLod + .Select(parts => (IReadOnlyList)new ReadOnlyCollection(parts.Select(x => x.Material).ToList())) + .ToList(); + Diagnostics = diagnostics; + } + + public PackFile InputFile { get; } + public PackFile GeometryFile { get; } + public PackFile? WsModelFile { get; } + public WsModelFile? WsModel { get; } + public RmvFile Model { get; } + public IReadOnlyList> PartsByLod { get; } + public IReadOnlyList> MaterialsByLod { get; } + public IReadOnlyList Diagnostics { get; } + + public bool UsesWsModel => WsModel != null; + + public IReadOnlyList FirstLod => PartsByLod.Count == 0 ? Array.Empty() : PartsByLod[0]; +} + +public sealed class ResolvedModelPart +{ + public ResolvedModelPart(int lodIndex, int partIndex, RmvModel model, ResolvedModelMaterial material) + { + LodIndex = lodIndex; + PartIndex = partIndex; + Model = model; + Material = material; + } + + public int LodIndex { get; } + public int PartIndex { get; } + public RmvModel Model { get; } + public ResolvedModelMaterial Material { get; } +} + +public sealed class ResolvedModelMaterial +{ + private ResolvedModelMaterial( + IRmvMaterial sourceMaterial, + WsModelMaterialFile? wsModelMaterial, + string? wsModelMaterialPath, + IReadOnlyDictionary textures, + bool hasExplicitAlpha, + bool alpha) + { + SourceMaterial = sourceMaterial; + WsModelMaterial = wsModelMaterial; + WsModelMaterialPath = wsModelMaterialPath; + Textures = textures; + HasExplicitAlpha = hasExplicitAlpha; + Alpha = alpha; + } + + public IRmvMaterial SourceMaterial { get; } + public WsModelMaterialFile? WsModelMaterial { get; } + public string? WsModelMaterialPath { get; } + public IReadOnlyDictionary Textures { get; } + public IReadOnlyDictionary EffectiveTextures => Textures; + public IReadOnlyList Parameters => WsModelMaterial?.Parameters ?? (IReadOnlyList)Array.Empty(); + public string ShaderPath => WsModelMaterial?.ShaderPath ?? string.Empty; + public bool HasExplicitAlpha { get; } + public bool Alpha { get; } + public bool UsesWsModelMaterial => WsModelMaterial != null; + + public string? GetTexture(TextureType textureType) + => Textures.TryGetValue(textureType, out var texturePath) ? texturePath : null; + + public static ResolvedModelMaterial Create( + IRmvMaterial sourceMaterial, + WsModelMaterialFile? wsModelMaterial = null, + string? wsModelMaterialPath = null) + { + ArgumentNullException.ThrowIfNull(sourceMaterial); + + var textures = new Dictionary(); + foreach (var texture in sourceMaterial.GetAllTextures()) + { + if (string.IsNullOrWhiteSpace(texture.Path) == false) + textures[texture.TexureType] = texture.Path; + } + + if (wsModelMaterial != null) + { + foreach (var texture in wsModelMaterial.Textures) + // Preserve an explicit empty WSModel slot. The renderer treats + // a present slot as an override, even when it disables a + // texture; the exporter will omit the empty path later. + textures[texture.Key] = texture.Value ?? string.Empty; + } + + var hasRmvAlpha = false; + var rmvAlpha = false; + if (sourceMaterial is WeightedMaterial weightedMaterial + && weightedMaterial.IntParams.TryGet(WeightedParamterIds.IntParams_Alpha_index, out var rmvAlphaValue)) + { + hasRmvAlpha = true; + rmvAlpha = rmvAlphaValue == 1; + } + + return new ResolvedModelMaterial( + sourceMaterial, + wsModelMaterial, + wsModelMaterialPath, + new ReadOnlyDictionary(textures), + wsModelMaterial != null || hasRmvAlpha, + wsModelMaterial?.Alpha ?? rmvAlpha); + } +} diff --git a/GameWorld/GameWorldCore/GameWorld.Core/Services/VariantMeshCompositionResolver.cs b/GameWorld/GameWorldCore/GameWorld.Core/Services/VariantMeshCompositionResolver.cs new file mode 100644 index 000000000..462cdf8e4 --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.Core/Services/VariantMeshCompositionResolver.cs @@ -0,0 +1,379 @@ +using System.Collections.ObjectModel; +using System.IO; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.GameFormats.Vmd; +using static Shared.GameFormats.Vmd.VariantMeshDefinition; + +namespace GameWorld.Core.Services; + +/// +/// Resolves a VariantMeshDefinition into the default, renderable composition +/// without creating scene nodes. The viewport still owns its scene-node +/// layout; exporters and other consumers can use this representation when +/// they need the same asset-selection rules without depending on MonoGame. +/// +public interface IVariantMeshCompositionResolver +{ + ResolvedVariantMeshComposition Resolve(PackFile inputFile); +} + +public sealed class VariantMeshCompositionResolver : IVariantMeshCompositionResolver +{ + private readonly IPackFileService _packFileService; + private readonly IModelAssetResolver _modelAssetResolver; + + public VariantMeshCompositionResolver( + IPackFileService packFileService, + IModelAssetResolver? modelAssetResolver = null) + { + _packFileService = packFileService; + _modelAssetResolver = modelAssetResolver ?? new ModelAssetResolver(packFileService); + } + + public ResolvedVariantMeshComposition Resolve(PackFile inputFile) + { + ArgumentNullException.ThrowIfNull(inputFile); + + var diagnostics = new List(); + var activeDefinitions = new List(); + var root = ResolveDefinition(inputFile, diagnostics, activeDefinitions, "root"); + + return new ResolvedVariantMeshComposition(inputFile, root, diagnostics); + } + + private ResolvedVariantMeshNode? ResolveDefinition( + PackFile file, + List diagnostics, + List activeDefinitions, + string context) + { + var definitionKey = GetDefinitionKey(file); + var activeIndex = activeDefinitions.FindIndex(x => string.Equals(x, definitionKey, StringComparison.OrdinalIgnoreCase)); + if (activeIndex >= 0) + { + var cycle = activeDefinitions.Skip(activeIndex).Append(definitionKey); + diagnostics.Add($"VariantMeshDefinition cycle detected while resolving {context}: {string.Join(" -> ", cycle)}."); + return null; + } + + activeDefinitions.Add(definitionKey); + try + { + VariantMesh definition; + try + { + definition = VariantMeshDefinitionLoader.Load(file); + } + catch (Exception exception) + { + diagnostics.Add($"Unable to parse VariantMeshDefinition '{DescribeFile(file)}' while resolving {context}: {exception.Message}"); + return null; + } + + return ResolveDefinition( + definition, + file, + diagnostics, + activeDefinitions, + context, + definitionKey); + } + finally + { + activeDefinitions.RemoveAt(activeDefinitions.Count - 1); + } + } + + private ResolvedVariantMeshNode ResolveDefinition( + VariantMesh definition, + PackFile ownerFile, + List diagnostics, + List activeDefinitions, + string context, + string definitionKey) + { + var node = new ResolvedVariantMeshNode( + definitionKey, + ownerFile, + definition.ModelReference); + + if (string.IsNullOrWhiteSpace(definition.ModelReference) == false) + { + node.ResolvedModelReference = ResolveCandidate( + definition.ModelReference, + diagnostics, + activeDefinitions, + $"model reference in {context}"); + } + + foreach (var slot in definition.ChildSlots ?? []) + { + var resolvedSlot = new ResolvedVariantMeshSlot( + slot.Name ?? string.Empty, + slot.AttachmentPoint ?? string.Empty); + + // VariantMeshDefinitionLoader preserves these as two collections + // and the viewport visits inline meshes before references. Keep + // that order so default selection matches the normal preview. + foreach (var childMesh in slot.ChildMeshes ?? []) + { + var candidate = ResolveCandidate( + childMesh, + ownerFile, + diagnostics, + activeDefinitions, + $"slot '{resolvedSlot.Name}' in {context}"); + if (candidate?.HasRenderableContent == true) + { + resolvedSlot.SelectedChild = candidate; + break; + } + } + + if (resolvedSlot.SelectedChild == null) + { + foreach (var childReference in slot.ChildReferences ?? []) + { + var candidate = ResolveCandidate( + childReference.Reference, + diagnostics, + activeDefinitions, + $"slot '{resolvedSlot.Name}' in {context}"); + if (candidate?.HasRenderableContent == true) + { + resolvedSlot.SelectedChild = candidate; + break; + } + } + } + + if (resolvedSlot.SelectedChild == null + && ((slot.ChildMeshes?.Count ?? 0) > 0 || (slot.ChildReferences?.Count ?? 0) > 0)) + { + diagnostics.Add($"No candidate could be resolved for slot '{resolvedSlot.Name}' in {context}."); + } + + node.Slots.Add(resolvedSlot); + } + + return node; + } + + private ResolvedVariantMeshNode? ResolveCandidate( + VariantMesh definition, + PackFile ownerFile, + List diagnostics, + List activeDefinitions, + string context) + { + var node = ResolveInlineDefinition(definition, ownerFile, diagnostics, activeDefinitions, context); + if (node?.HasRenderableContent != true) + return null; + return node; + } + + private ResolvedVariantMeshNode? ResolveInlineDefinition( + VariantMesh definition, + PackFile ownerFile, + List diagnostics, + List activeDefinitions, + string context) + { + var node = new ResolvedVariantMeshNode( + $"{GetDefinitionKey(ownerFile)}::{context}", + ownerFile, + definition.ModelReference); + + if (string.IsNullOrWhiteSpace(definition.ModelReference) == false) + { + node.ResolvedModelReference = ResolveCandidate( + definition.ModelReference, + diagnostics, + activeDefinitions, + $"model reference in {context}"); + } + + foreach (var slot in definition.ChildSlots ?? []) + { + var resolvedSlot = new ResolvedVariantMeshSlot( + slot.Name ?? string.Empty, + slot.AttachmentPoint ?? string.Empty); + + foreach (var childMesh in slot.ChildMeshes ?? []) + { + var candidate = ResolveCandidate(childMesh, ownerFile, diagnostics, activeDefinitions, $"slot '{resolvedSlot.Name}' in {context}"); + if (candidate?.HasRenderableContent == true) + { + resolvedSlot.SelectedChild = candidate; + break; + } + } + + if (resolvedSlot.SelectedChild == null) + { + foreach (var childReference in slot.ChildReferences ?? []) + { + var candidate = ResolveCandidate(childReference.Reference, diagnostics, activeDefinitions, $"slot '{resolvedSlot.Name}' in {context}"); + if (candidate?.HasRenderableContent == true) + { + resolvedSlot.SelectedChild = candidate; + break; + } + } + } + + if (resolvedSlot.SelectedChild == null + && ((slot.ChildMeshes?.Count ?? 0) > 0 || (slot.ChildReferences?.Count ?? 0) > 0)) + { + diagnostics.Add($"No candidate could be resolved for slot '{resolvedSlot.Name}' in {context}."); + } + + node.Slots.Add(resolvedSlot); + } + + return node; + } + + private ResolvedVariantMeshNode? ResolveCandidate( + string? reference, + List diagnostics, + List activeDefinitions, + string context) + { + if (string.IsNullOrWhiteSpace(reference)) + { + diagnostics.Add($"Empty candidate in {context}."); + return null; + } + + var candidateFile = FindReference(reference); + if (candidateFile == null) + { + diagnostics.Add($"Candidate '{reference}' in {context} was not found; trying the next candidate."); + return null; + } + + if (IsVmd(candidateFile)) + return ResolveDefinition(candidateFile, diagnostics, activeDefinitions, context); + + if (!IsModel(candidateFile)) + { + diagnostics.Add($"Candidate '{DescribeFile(candidateFile)}' in {context} is not an RMV2, WSModel, or VariantMeshDefinition."); + return null; + } + + try + { + var asset = _modelAssetResolver.Resolve(candidateFile); + var node = new ResolvedVariantMeshNode( + GetDefinitionKey(candidateFile), + candidateFile, + null) + { + ModelAsset = asset + }; + return node; + } + catch (Exception exception) + { + diagnostics.Add($"Candidate '{DescribeFile(candidateFile)}' in {context} could not be loaded; trying the next candidate. {exception.Message}"); + return null; + } + } + + private PackFile? FindReference(string reference) + { + var normalizedReference = NormalizePackPath(reference).ToLowerInvariant(); + // Keep candidate lookup identical to ComplexMeshLoader: VMD paths are + // pack-root-relative, not relative to the definition's containing + // folder. This also makes the default-candidate order deterministic + // across the viewport and export paths. + return _packFileService.FindFile(normalizedReference); + } + + private string GetDefinitionKey(PackFile file) + => NormalizeCyclePath(TryGetPackPath(file) ?? file.Name); + + private string? TryGetPackPath(PackFile file) + { + try + { + var fullPath = _packFileService.GetFullPath(file); + return string.IsNullOrWhiteSpace(fullPath) ? file.Name : fullPath; + } + catch + { + return file.Name; + } + } + + private string DescribeFile(PackFile file) + => TryGetPackPath(file) ?? file.Name; + + private static string NormalizePackPath(string path) + => path.Replace('/', '\\').Trim().TrimStart('\\'); + + private static string NormalizeCyclePath(string path) + => NormalizePackPath(path).ToLowerInvariant(); + + private static bool IsVmd(PackFile file) + => file.Name.EndsWith(".variantmeshdefinition", StringComparison.OrdinalIgnoreCase); + + private static bool IsModel(PackFile file) + => file.Name.EndsWith(".rigid_model_v2", StringComparison.OrdinalIgnoreCase) + || file.Name.EndsWith(".wsmodel", StringComparison.OrdinalIgnoreCase); +} + +public sealed class ResolvedVariantMeshComposition +{ + public ResolvedVariantMeshComposition( + PackFile inputFile, + ResolvedVariantMeshNode? root, + IReadOnlyList diagnostics) + { + InputFile = inputFile; + Root = root; + Diagnostics = new ReadOnlyCollection(diagnostics.ToList()); + } + + public PackFile InputFile { get; } + public ResolvedVariantMeshNode? Root { get; } + public IReadOnlyList Diagnostics { get; } + public bool HasRenderableContent => Root?.HasRenderableContent == true; +} + +public sealed class ResolvedVariantMeshNode +{ + public ResolvedVariantMeshNode(string key, PackFile sourceFile, string? modelReference) + { + Key = key; + SourceFile = sourceFile; + ModelReference = modelReference; + } + + public string Key { get; } + public PackFile SourceFile { get; } + public string? ModelReference { get; } + public ResolvedModelAsset? ModelAsset { get; set; } + public ResolvedVariantMeshNode? ResolvedModelReference { get; set; } + public List Slots { get; } = []; + + public bool HasRenderableContent + => (ModelAsset?.FirstLod.Count ?? 0) > 0 + || ResolvedModelReference?.HasRenderableContent == true + || Slots.Any(x => x.SelectedChild?.HasRenderableContent == true); +} + +public sealed class ResolvedVariantMeshSlot +{ + public ResolvedVariantMeshSlot(string name, string attachmentPoint) + { + Name = name; + AttachmentPoint = attachmentPoint; + } + + public string Name { get; } + public string AttachmentPoint { get; } + public ResolvedVariantMeshNode? SelectedChild { get; set; } +} diff --git a/GameWorld/GameWorldCore/GameWorld.Core/Services/WsModelMaterialProvider.cs b/GameWorld/GameWorldCore/GameWorld.Core/Services/WsModelMaterialProvider.cs index 88a4a6a4c..23dd0ad47 100644 --- a/GameWorld/GameWorldCore/GameWorld.Core/Services/WsModelMaterialProvider.cs +++ b/GameWorld/GameWorldCore/GameWorld.Core/Services/WsModelMaterialProvider.cs @@ -8,6 +8,12 @@ namespace GameWorld.Core.Services { + /// + /// Compatibility adapter for older callers. New scene loading resolves + /// geometry and materials through and + /// should not create this provider directly. + /// + [Obsolete("Use IModelAssetResolver and CapabilityMaterialFactory instead.")] public class WsModelMaterialProvider { private readonly IPackFileService _packFileService; @@ -69,15 +75,15 @@ public void ValidateWsModelMaterial(int[] meshCountInLods) public CapabilityMaterial ConstructMaterial(int lodIndex, int partIndex, IRmvMaterial fallbackMaterial) { if (_wsModelFile == null) - return _materialFactory.Create(fallbackMaterial, null); + return CreateResolvedMaterial(fallbackMaterial, null); var materialPath = _wsModelFile.MaterialList.FirstOrDefault(x => x.LodIndex == lodIndex && x.PartIndex == partIndex); if (materialPath == null) - return _materialFactory.Create(fallbackMaterial, null); + return CreateResolvedMaterial(fallbackMaterial, null); var wsMaterialPath = _packFileService.FindFile(materialPath.MaterialPath); if (wsMaterialPath == null) - return _materialFactory.Create(fallbackMaterial, null); + return CreateResolvedMaterial(fallbackMaterial, null); var found =_materialCache.TryGetValue(materialPath.MaterialPath, out var capMaterial); if (found) @@ -88,7 +94,8 @@ public CapabilityMaterial ConstructMaterial(int lodIndex, int partIndex, IRmvMat { var mFile = new WsModelMaterialFile(wsMaterialPath); var wsModelMaterial = mFile; - shader = _materialFactory.Create(fallbackMaterial, wsModelMaterial); + var resolvedMaterial = ResolvedModelMaterial.Create(fallbackMaterial, wsModelMaterial, materialPath.MaterialPath); + shader = _materialFactory.Create(resolvedMaterial.SourceMaterial, resolvedMaterial.WsModelMaterial); } catch (Exception e) { @@ -104,7 +111,11 @@ public CapabilityMaterial ConstructMaterial(int lodIndex, int partIndex, IRmvMat _materialCache.Add(materialPath.MaterialPath, shader); return shader; } + + private CapabilityMaterial CreateResolvedMaterial(IRmvMaterial fallbackMaterial, WsModelMaterialFile? wsModelMaterial) + { + var resolvedMaterial = ResolvedModelMaterial.Create(fallbackMaterial, wsModelMaterial); + return _materialFactory.Create(resolvedMaterial.SourceMaterial, resolvedMaterial.WsModelMaterial); + } } } - - diff --git a/GameWorld/GameWorldCore/GameWorld.CoreTest/Services/ModelAssetResolverTests.cs b/GameWorld/GameWorldCore/GameWorld.CoreTest/Services/ModelAssetResolverTests.cs new file mode 100644 index 000000000..62043ab0f --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.CoreTest/Services/ModelAssetResolverTests.cs @@ -0,0 +1,83 @@ +using GameWorld.Core.Test.TestUtility; +using GameWorld.Core.Services; +using Shared.GameFormats.RigidModel; +using Shared.GameFormats.RigidModel.MaterialHeaders; +using Shared.GameFormats.RigidModel.Types; +using Shared.GameFormats.WsModel; + +namespace GameWorld.Core.Test.Services; + +public class ModelAssetResolverTests +{ + [Test] + public void EffectiveMaterialUsesWsModelTexturesAndKeepsRmvFallbacks() + { + var rmvMaterial = RmvMaterialHelper.Create(ModelMaterialEnum.weighted); + rmvMaterial.SetTexture(TextureType.Diffuse, "textures/rmv_diffuse.dds"); + rmvMaterial.SetTexture(TextureType.Normal, "textures/rmv_normal.dds"); + + var wsMaterial = new WsModelMaterialFile + { + Alpha = true, + Textures = new Dictionary + { + [TextureType.Diffuse] = "textures/ws_diffuse.dds" + } + }; + + var resolved = ResolvedModelMaterial.Create(rmvMaterial, wsMaterial, "materials/body.material"); + + Assert.That(resolved.UsesWsModelMaterial, Is.True); + Assert.That(resolved.Alpha, Is.True); + Assert.That(resolved.WsModelMaterialPath, Is.EqualTo("materials/body.material")); + Assert.That(resolved.Textures[TextureType.Diffuse], Is.EqualTo("textures/ws_diffuse.dds")); + Assert.That(resolved.Textures[TextureType.Normal], Is.EqualTo("textures/rmv_normal.dds")); + } + + [Test] + public void EffectiveMaterialWithoutWsModelUsesRmvTextures() + { + var rmvMaterial = RmvMaterialHelper.Create(ModelMaterialEnum.weighted); + rmvMaterial.SetTexture(TextureType.Diffuse, "textures/rmv_diffuse.dds"); + + var resolved = ResolvedModelMaterial.Create(rmvMaterial); + + Assert.That(resolved.UsesWsModelMaterial, Is.False); + Assert.That(resolved.Alpha, Is.False); + Assert.That(resolved.Textures[TextureType.Diffuse], Is.EqualTo("textures/rmv_diffuse.dds")); + } + + [TestCase(0, false)] + [TestCase(1, true)] + public void EffectiveMaterialUsesRmvWeightedAlpha(int alphaValue, bool expectedAlpha) + { + var rmvMaterial = (WeightedMaterial)RmvMaterialHelper.Create(ModelMaterialEnum.weighted); + rmvMaterial.IntParams.Set(WeightedParamterIds.IntParams_Alpha_index, alphaValue); + + var resolved = ResolvedModelMaterial.Create(rmvMaterial); + + Assert.That(resolved.HasExplicitAlpha, Is.True); + Assert.That(resolved.Alpha, Is.EqualTo(expectedAlpha)); + } + + [Test] + public void WsModelAlphaOverridesRmvWeightedAlpha() + { + var rmvMaterial = (WeightedMaterial)RmvMaterialHelper.Create(ModelMaterialEnum.weighted); + rmvMaterial.IntParams.Set(WeightedParamterIds.IntParams_Alpha_index, 1); + + var resolved = ResolvedModelMaterial.Create(rmvMaterial, new WsModelMaterialFile { Alpha = false }); + + Assert.That(resolved.HasExplicitAlpha, Is.True); + Assert.That(resolved.Alpha, Is.False); + } + + [Test] + public void EffectiveMaterialLeavesUnknownRmvAlphaSafe() + { + var resolved = ResolvedModelMaterial.Create(RmvMaterialHelper.Create(ModelMaterialEnum.custom_terrain)); + + Assert.That(resolved.HasExplicitAlpha, Is.False); + Assert.That(resolved.Alpha, Is.False); + } +} diff --git a/GameWorld/GameWorldCore/GameWorld.CoreTest/Services/VariantMeshCompositionResolverTests.cs b/GameWorld/GameWorldCore/GameWorld.CoreTest/Services/VariantMeshCompositionResolverTests.cs new file mode 100644 index 000000000..8a8431628 --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.CoreTest/Services/VariantMeshCompositionResolverTests.cs @@ -0,0 +1,182 @@ +using GameWorld.Core.Services; +using Moq; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.GameFormats.RigidModel; +using Shared.GameFormats.RigidModel.LodHeader; +using Shared.GameFormats.RigidModel.MaterialHeaders; +using Shared.GameFormats.RigidModel.Types; +using Shared.TestUtility; +using Test.TestingUtility.TestUtility; + +namespace GameWorld.Core.Test.Services; + +public class VariantMeshCompositionResolverTests +{ + private readonly string _romePack = PathHelper.GetDataFolder("Data\\Rome_Man_And_Shield_Pack"); + + [Test] + public void SelectsFirstCandidateThatLoadsSuccessfully() + { + var first = PackFile.CreateFromASCII("first.rigid_model_v2", "first"); + var second = PackFile.CreateFromASCII("second.rigid_model_v2", "second"); + var root = PackFile.CreateFromASCII("root.variantmeshdefinition", """ + + + + + + + """); + + var files = new[] { root, first, second }; + var packFileService = CreatePackFileService(files); + var modelResolver = new Mock(MockBehavior.Strict); + modelResolver + .Setup(x => x.Resolve(first)) + .Throws(new ModelAssetResolutionException("broken first candidate")); + modelResolver + .Setup(x => x.Resolve(second)) + .Returns(CreatePlaceholderAsset(second)); + + var result = new VariantMeshCompositionResolver(packFileService.Object, modelResolver.Object).Resolve(root); + + Assert.That(result.HasRenderableContent, Is.True); + Assert.That(result.Root!.Slots, Has.Count.EqualTo(1)); + Assert.That(result.Root.Slots[0].AttachmentPoint, Is.EqualTo("hand")); + Assert.That(result.Root.Slots[0].SelectedChild!.ModelAsset!.InputFile, Is.SameAs(second)); + Assert.That(result.Diagnostics.Any(x => x.Contains("broken first candidate", StringComparison.Ordinal)), Is.True); + } + + [Test] + public void DetectsNestedCaseInsensitiveCycleWithDiagnostic() + { + var root = PackFile.CreateFromASCII("Models\\Root.variantmeshdefinition", """ + + + + + + """); + var child = PackFile.CreateFromASCII("Models\\Child.variantmeshdefinition", """ + + + + + + """); + + var packFileService = CreatePackFileService(root, child); + var result = new VariantMeshCompositionResolver(packFileService.Object, new Mock().Object).Resolve(root); + + Assert.That(result.HasRenderableContent, Is.False); + Assert.That(result.Diagnostics.Any(x => x.Contains("cycle", StringComparison.OrdinalIgnoreCase)), Is.True); + Assert.That(result.Diagnostics.Any(x => x.Contains("root.variantmeshdefinition", StringComparison.OrdinalIgnoreCase)), Is.True); + } + + [Test] + public void ResolvesNestedVmdModelReference() + { + var root = PackFile.CreateFromASCII("Models\\Root.variantmeshdefinition", """ + + + + + + """); + var child = PackFile.CreateFromASCII("Models\\Child.variantmeshdefinition", """ + + """); + var model = PackFile.CreateFromASCII("Models\\Body.rigid_model_v2", "body"); + var packFileService = CreatePackFileService(root, child, model); + var modelResolver = new Mock(MockBehavior.Strict); + modelResolver + .Setup(x => x.Resolve(model)) + .Returns(CreatePlaceholderAsset(model)); + + var result = new VariantMeshCompositionResolver(packFileService.Object, modelResolver.Object).Resolve(root); + + Assert.That(result.HasRenderableContent, Is.True); + var selectedChild = result.Root!.Slots.Single().SelectedChild; + Assert.That(selectedChild, Is.Not.Null); + Assert.That(selectedChild!.ResolvedModelReference!.ModelAsset!.InputFile, Is.SameAs(model)); + Assert.That(result.Diagnostics, Is.Empty); + } + + [Test] + public void DoesNotResolveCandidateRelativeToDefinitionFolder() + { + var root = PackFile.CreateFromASCII("Models\\Root.variantmeshdefinition", """ + + + + + + """); + var nestedCandidate = PackFile.CreateFromASCII("Models\\Child.rigid_model_v2", "child"); + var packFileService = CreatePackFileService(root, nestedCandidate); + var modelResolver = new Mock(MockBehavior.Strict); + + var result = new VariantMeshCompositionResolver(packFileService.Object, modelResolver.Object).Resolve(root); + + Assert.That(result.HasRenderableContent, Is.False); + Assert.That(modelResolver.Invocations, Is.Empty, + "Root-relative lookup must not reinterpret a missing candidate as relative to the VMD folder."); + Assert.That(result.Diagnostics.Any(x => x.Contains("Child.rigid_model_v2", StringComparison.OrdinalIgnoreCase)), Is.True); + } + + [Test] + public void ResolvesTrackedShieldVariantMeshDefinition() + { + var packFileService = PackFileSerivceTestHelper.Create(_romePack); + var definition = packFileService.FindFile(@"variantmeshes\_variantmodels\man\shield\celtic_oval_patterns.variantmeshdefinition"); + Assert.That(definition, Is.Not.Null); + + var result = new VariantMeshCompositionResolver(packFileService, new ModelAssetResolver(packFileService)).Resolve(definition!); + + Assert.That(result.HasRenderableContent, Is.True); + Assert.That(result.Root!.Slots, Has.Count.EqualTo(1)); + Assert.That(result.Root.Slots[0].SelectedChild, Is.Not.Null); + + // The tracked slot contains an inline VARIANT_MESH node whose model + // attribute is represented by ResolvedModelReference. The exporter + // intentionally traverses that node before its slot children. + var selectedChild = result.Root.Slots[0].SelectedChild!; + Assert.That(selectedChild.ResolvedModelReference, Is.Not.Null); + Assert.That(selectedChild.ResolvedModelReference!.ModelAsset, Is.Not.Null); + Assert.That(selectedChild.ResolvedModelReference.ModelAsset!.FirstLod, Is.Not.Empty); + } + + private static Mock CreatePackFileService(params PackFile[] files) + { + var byPath = files.ToDictionary(x => Normalize(x.Name), StringComparer.OrdinalIgnoreCase); + var mock = new Mock(MockBehavior.Loose); + mock.Setup(x => x.FindFile(It.IsAny(), It.IsAny())) + .Returns((string path, IPackFileContainer? _) => byPath.GetValueOrDefault(Normalize(path))); + mock.Setup(x => x.GetFullPath(It.IsAny(), It.IsAny())) + .Returns((PackFile file, IPackFileContainer? _) => files.First(x => ReferenceEquals(x, file)).Name); + return mock; + } + + private static ResolvedModelAsset CreatePlaceholderAsset(PackFile file) + { + var model = new RmvModel + { + Material = new WeightedMaterial(), + Mesh = new RmvMesh { VertexList = [], IndexList = [] } + }; + var header = new RmvFileHeader { Version = RmvVersionEnum.RMV2_V6, LodCount = 1 }; + header.SkeletonName = string.Empty; + var rmv = new RmvFile + { + Header = header, + ModelList = new[] { new[] { model } }, + LodHeaders = new[] { LodHeaderFactory.Create().CreateEmpty(RmvVersionEnum.RMV2_V6, 0, 0, 0) } + }; + var part = new ResolvedModelPart(0, 0, model, ResolvedModelMaterial.Create(model.Material)); + return new(file, file, null, null, rmv, new[] { new[] { part } }, []); + } + + private static string Normalize(string path) + => path.Replace('/', '\\').Trim().ToLowerInvariant(); +} diff --git a/Shared/GameFiles/Vmd/VariantMeshDefinition.cs b/Shared/GameFiles/Vmd/VariantMeshDefinition.cs index 2323505ca..9b7149a7e 100644 --- a/Shared/GameFiles/Vmd/VariantMeshDefinition.cs +++ b/Shared/GameFiles/Vmd/VariantMeshDefinition.cs @@ -58,7 +58,7 @@ public class VariantMesh [XmlElement("SLOT")] - public List ChildSlots { get; set; } + public List ChildSlots { get; set; } = []; [XmlElement("META_DATA")] public List MetaDataList { get; set; } @@ -67,7 +67,7 @@ public void FixStrings() { if (ModelReference != null) ModelReference = ModelReference.ToLower().Replace("//", "\\"); - foreach (var item in ChildSlots) + foreach (var item in ChildSlots ?? []) item.FixStrings(); } } @@ -107,20 +107,20 @@ public class SLOT [XmlElement("VARIANT_MESH")] - public List ChildMeshes { get; set; } + public List ChildMeshes { get; set; } = []; [XmlElement("VARIANT_MESH_REFERENCE")] - public List ChildReferences { get; set; } + public List ChildReferences { get; set; } = []; public void FixStrings() { if (Name != null) Name = Name.ToLower().Replace("//", "\\"); - foreach (var item in ChildMeshes) + foreach (var item in ChildMeshes ?? []) item.FixStrings(); - foreach (var item in ChildReferences) + foreach (var item in ChildReferences ?? []) item.FixStrings(); } } diff --git a/Testing/Shared/TestUtility/PathHelper.cs b/Testing/Shared/TestUtility/PathHelper.cs index f7e4f6243..11a6d4103 100644 --- a/Testing/Shared/TestUtility/PathHelper.cs +++ b/Testing/Shared/TestUtility/PathHelper.cs @@ -12,9 +12,11 @@ public static string GetDataFolder(string folder, string rootDir = "TheAssetEdit { var currentDirectory = TestContext.CurrentContext.TestDirectory; - var index = currentDirectory.LastIndexOf(rootDir, StringComparison.InvariantCultureIgnoreCase); - var rootPath = currentDirectory.Substring(0, index) + rootDir; - var fullPath = Path.Combine(rootPath, folder).ToLower(); + var rootPath = FindRepositoryRoot(currentDirectory, rootDir); + if (rootPath == null) + throw new Exception($"Unable to find repository root '{rootDir}' or AssetEditor.sln from test directory {currentDirectory}"); + + var fullPath = CombineRelativePath(rootPath, folder); if (Directory.Exists(fullPath) == false) throw new Exception($"Unable to find data directory {fullPath}. TestFolder : {currentDirectory}. InputFolder: {folder}"); @@ -28,21 +30,11 @@ public static string GetDataFile(string fileName, string rootDir = "TheAssetEdit if (string.IsNullOrEmpty(currentDirectory)) return ""; - while (true) - { - var fileNameOnly = Path.GetFileName(currentDirectory); // get last foldername - if (string.IsNullOrEmpty(fileNameOnly)) - return ""; - - if (fileNameOnly.ToLower() == rootDir.ToLower()) - break; - - currentDirectory = Path.GetDirectoryName(currentDirectory); // go one folder UP - if (string.IsNullOrEmpty(currentDirectory)) // reached root, nothing foun - return ""; - } + var rootPath = FindRepositoryRoot(currentDirectory, rootDir); + if (rootPath == null) + return ""; - var fullPath = currentDirectory + $@"\{subDir}\" + fileName; + var fullPath = CombineRelativePath(rootPath, subDir, fileName); if (File.Exists(fullPath) == false) throw new Exception($"Unable to find data file {fileName}"); @@ -63,5 +55,49 @@ public static string GetFileContentAsString(string path) return Encoding.UTF8.GetString(bytes); } + private static string? FindRepositoryRoot(string currentDirectory, string rootDir) + { + if (string.IsNullOrWhiteSpace(currentDirectory)) + return null; + + var directory = new DirectoryInfo(currentDirectory); + while (directory != null) + { + // Keep honoring the historical rootDir argument, but prefer whichever + // valid repository marker is nearest to the test output directory. + if (!string.IsNullOrWhiteSpace(rootDir) && + string.Equals(directory.Name, rootDir, StringComparison.OrdinalIgnoreCase)) + { + return directory.FullName; + } + + if (File.Exists(Path.Combine(directory.FullName, "AssetEditor.sln"))) + return directory.FullName; + + directory = directory.Parent; + } + + return null; + } + + private static string CombineRelativePath(string rootPath, params string[] parts) + { + var path = rootPath; + foreach (var part in parts) + { + if (string.IsNullOrEmpty(part)) + continue; + + // Test data callers historically pass Windows-style paths even when + // tests run on Linux. Normalize both separators before combining. + var normalizedPart = part + .Replace('\\', Path.DirectorySeparatorChar) + .Replace('/', Path.DirectorySeparatorChar); + path = Path.Combine(path, normalizedPart); + } + + return path; + } + } } diff --git a/Testing/Shared/TestUtility/PathHelperTests.cs b/Testing/Shared/TestUtility/PathHelperTests.cs new file mode 100644 index 000000000..d4a37f9f5 --- /dev/null +++ b/Testing/Shared/TestUtility/PathHelperTests.cs @@ -0,0 +1,24 @@ +using NUnit.Framework; + +namespace Test.TestingUtility.TestUtility; + +public class PathHelperTests +{ + [Test] + public void FindsDataFromSolutionRootWhenCheckoutNameDiffersFromLegacyRootName() + { + var path = PathHelper.GetDataFolder("Data\\Rome_Man_And_Shield_Pack", "__nonexistent_checkout_name__"); + + Assert.That(Directory.Exists(path), Is.True); + Assert.That(Path.GetFileName(path), Is.EqualTo("Rome_Man_And_Shield_Pack")); + } + + [Test] + public void FindsDataFileUsingPlatformIndependentPathCombining() + { + var path = PathHelper.GetDataFile("Throt.pack", "TheAssetEditor", "Data"); + + Assert.That(File.Exists(path), Is.True); + Assert.That(Path.GetFileName(path), Is.EqualTo("Throt.pack")); + } +} From c172e3e73ec8278c93cd4608f890cda99c38d1ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tibor=20Ili=C4=87?= Date: Mon, 14 Sep 2026 23:47:56 +0200 Subject: [PATCH 3/8] Wire glTF animation selection for composed models --- AssetEditor/Language_Cn.json | 6 +- AssetEditor/Language_En.json | 5 +- AssetEditor/Language_Fr.json | 4 + .../DependencyInjectionContainer.cs | 2 + .../RmvToGltfAnimationSourceResolver.cs | 235 ++++++++++++++++++ .../Exporters/RmvToGltf/RmvToGltfExporter.cs | 67 ++--- .../Exporting/Presentation/ExportWindow.xaml | 2 +- .../Presentation/ExporterCoreViewModel.cs | 4 + .../Presentation/IExporterViewModel.cs | 7 + .../RmvToGltf/RmvToGltfExporterView.xaml | 29 +++ .../RmvToGltf/RmvToGltfExporterViewModel.cs | 228 ++++++++++++++++- .../RmvToGlft/GltfAnimationCatalogTests.cs | 209 ++++++++++++++++ 12 files changed, 747 insertions(+), 51 deletions(-) create mode 100644 Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfAnimationSourceResolver.cs create mode 100644 Editors/ImportExportEditor/Test.ImportExport/Exporting/Exporters/RmvToGlft/GltfAnimationCatalogTests.cs diff --git a/AssetEditor/Language_Cn.json b/AssetEditor/Language_Cn.json index 3a0f7a163..83e0ad945 100644 --- a/AssetEditor/Language_Cn.json +++ b/AssetEditor/Language_Cn.json @@ -449,6 +449,10 @@ "RmvToGltfExporter.ConvertMaterial": "转换材质", "RmvToGltfExporter.ConvertNormal": "转换法线", "RmvToGltfExporter.ExportAnimations": "导出动画", + "RmvToGltfExporter.Animations": "动画:", + "RmvToGltfExporter.SelectAll": "全选", + "RmvToGltfExporter.SelectNone": "全部取消", + "RmvToGltfExporter.FilterAnimations": "筛选动画", "RmvToGltfExporter.Tooltips.ConvertMaterial": "将材质纹理转换为 Blender 喜欢的版本", "RmvToGltfExporter.Tooltips.ConvertNormal": "将法线贴图转换为蓝色版本 - 许多软件使用,包括 Blender", @@ -916,4 +920,4 @@ "PackFileCache.BuildingCache": "正在为 {0} 构建 Pack 文件缓存。这可能需要一些时间,请稍候...", "PackFileCache.BuildingCache.Title": "正在构建缓存", "PackFileCache.Description": "缓存通过将预处理的游戏文件数据存储在磁盘上来加快启动速度并减少内存使用。" -} \ No newline at end of file +} diff --git a/AssetEditor/Language_En.json b/AssetEditor/Language_En.json index b73133b44..ae3102d99 100644 --- a/AssetEditor/Language_En.json +++ b/AssetEditor/Language_En.json @@ -449,6 +449,10 @@ "RmvToGltfExporter.ConvertMaterial": "Convert Material", "RmvToGltfExporter.ConvertNormal": "Convert Normal", "RmvToGltfExporter.ExportAnimations": "Export Animations", + "RmvToGltfExporter.Animations": "Animations: ", + "RmvToGltfExporter.SelectAll": "Select all", + "RmvToGltfExporter.SelectNone": "Select none", + "RmvToGltfExporter.FilterAnimations": "Filter animations", "RmvToGltfExporter.Tooltips.ConvertMaterial": "Convert the Material texture into the version Blender prefers", "RmvToGltfExporter.Tooltips.ConvertNormal": "Convert the normal map texture into blue version - used by a lot of software, including blender", @@ -917,4 +921,3 @@ "PackFileCache.BuildingCache.Title": "Building Cache", "PackFileCache.Description": "The cache allows faster startup times and reduces memory usage by storing pre-processed game file data on disk." } - diff --git a/AssetEditor/Language_Fr.json b/AssetEditor/Language_Fr.json index 4d6e8e09a..52f53428e 100644 --- a/AssetEditor/Language_Fr.json +++ b/AssetEditor/Language_Fr.json @@ -421,6 +421,10 @@ "RmvToGltfExporter.ConvertMaterial": "Convert Material", "RmvToGltfExporter.ConvertNormal": "Convert Normal", "RmvToGltfExporter.ExportAnimations": "Export Animations", + "RmvToGltfExporter.Animations": "Animations: ", + "RmvToGltfExporter.SelectAll": "Select all", + "RmvToGltfExporter.SelectNone": "Select none", + "RmvToGltfExporter.FilterAnimations": "Filter animations", "RmvToGltfExporter.Tooltips.ConvertMaterial": "Convert the Material texture into the version Blender prefers", "RmvToGltfExporter.Tooltips.ConvertNormal": "Convert the normal map texture into blue version - used by a lot of software, including blender", "AudioEditor.Menu.File": "File", diff --git a/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs b/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs index 5e768febc..bfda50063 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs @@ -41,6 +41,7 @@ public override void Register(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(x => x.GetRequiredService()); services.AddTransient(); // Importer ViewModels @@ -71,6 +72,7 @@ public override void Register(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddSingleton(); diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfAnimationSourceResolver.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfAnimationSourceResolver.cs new file mode 100644 index 000000000..bb32585ad --- /dev/null +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfAnimationSourceResolver.cs @@ -0,0 +1,235 @@ +using System.Collections.ObjectModel; +using GameWorld.Core.Services; +using Shared.Core.PackFiles.Models; + +namespace Editors.ImportExport.Exporting.Exporters.RmvToGltf; + +/// +/// The animation catalog available for an RMV2, WSModel, or composed VMD +/// export. It retains the original animation references and their pack-file +/// containers so callers can resolve the exact selected file later. +/// +public sealed class GltfAnimationCatalog +{ + public GltfAnimationCatalog( + string? skeletonName, + bool hasSkeletonFile, + IReadOnlyList animations, + IReadOnlyList diagnostics) + { + SkeletonName = skeletonName; + HasSkeletonFile = hasSkeletonFile; + Animations = new ReadOnlyCollection(animations.ToList()); + Diagnostics = new ReadOnlyCollection(diagnostics.ToList()); + } + + public string? SkeletonName { get; } + public bool HasSkeletonFile { get; } + public IReadOnlyList Animations { get; } + public IReadOnlyList Diagnostics { get; } +} + +public interface IGltfAnimationCatalogResolver +{ + GltfAnimationCatalog Resolve(PackFile inputFile); +} + +/// +/// Resolves the animation catalog using the same model, VMD composition, and +/// skeleton lookup services as the exporter. VMD components are visited in the +/// same order as exporter flattening: the node's model, its model reference, +/// then selected slot children. +/// +public sealed class GltfAnimationCatalogResolver : IGltfAnimationCatalogResolver +{ + private readonly IModelAssetResolver _modelAssetResolver; + private readonly IVariantMeshCompositionResolver _variantMeshResolver; + private readonly ISkeletonAnimationLookUpHelper _skeletonAnimationLookUpHelper; + + public GltfAnimationCatalogResolver( + IModelAssetResolver modelAssetResolver, + IVariantMeshCompositionResolver variantMeshResolver, + ISkeletonAnimationLookUpHelper skeletonAnimationLookUpHelper) + { + _modelAssetResolver = modelAssetResolver; + _variantMeshResolver = variantMeshResolver; + _skeletonAnimationLookUpHelper = skeletonAnimationLookUpHelper; + } + + public GltfAnimationCatalog Resolve(PackFile inputFile) + { + ArgumentNullException.ThrowIfNull(inputFile); + + var source = IsVariantMeshDefinition(inputFile) + ? ResolveVariantMeshSource(inputFile) + : ResolveModelSource(inputFile); + + var diagnostics = new List(source.Diagnostics); + var skeletonName = source.SkeletonName; + if (string.IsNullOrWhiteSpace(skeletonName)) + return new GltfAnimationCatalog(null, false, [], diagnostics); + + try + { + var skeletonFile = _skeletonAnimationLookUpHelper.GetSkeletonFileFromName(skeletonName); + if (skeletonFile == null) + { + diagnostics.Add($"Skeleton '{skeletonName}' was not found in the loaded packs."); + return new GltfAnimationCatalog(skeletonName, false, [], diagnostics); + } + + var animations = _skeletonAnimationLookUpHelper.GetAnimationsForSkeleton(skeletonName); + return new GltfAnimationCatalog(skeletonName, true, animations, diagnostics); + } + catch (Exception exception) + { + diagnostics.Add($"Unable to load animation catalog for skeleton '{skeletonName}': {exception.Message}"); + return new GltfAnimationCatalog(skeletonName, false, [], diagnostics); + } + } + + private RmvToGltfAnimationSource ResolveModelSource(PackFile inputFile) + { + try + { + var asset = _modelAssetResolver.Resolve(inputFile); + var diagnostics = new List(asset.Diagnostics); + var skeletonName = SelectSharedSkeletonName([asset], diagnostics); + return new RmvToGltfAnimationSource(skeletonName, diagnostics); + } + catch (Exception exception) + { + return new RmvToGltfAnimationSource( + null, + [$"Unable to resolve model '{inputFile.Name}' for animation export: {exception.Message}"]); + } + } + + private RmvToGltfAnimationSource ResolveVariantMeshSource(PackFile inputFile) + { + ResolvedVariantMeshComposition composition; + try + { + composition = _variantMeshResolver.Resolve(inputFile); + } + catch (Exception exception) + { + return new RmvToGltfAnimationSource( + null, + [$"Unable to resolve VariantMeshDefinition '{inputFile.Name}' for animation export: {exception.Message}"]); + } + + var diagnostics = new List(composition.Diagnostics); + + if (composition.Root == null || composition.HasRenderableContent == false) + { + diagnostics.Add($"VariantMeshDefinition '{inputFile.Name}' has no renderable model components."); + return new RmvToGltfAnimationSource(null, diagnostics); + } + + var assets = EnumerateComponents(composition.Root) + .Select(x => x.Asset) + .ToList(); + var skeletonName = SelectSharedSkeletonName(assets, diagnostics); + return new RmvToGltfAnimationSource(skeletonName, diagnostics); + } + + /// + /// Returns composed model components in the export order. Keeping this + /// traversal shared avoids the UI selecting animations for a different + /// VMD root skeleton than the exporter uses. + /// + internal static IEnumerable EnumerateComponents(ResolvedVariantMeshNode node) + { + if (node.ModelAsset != null) + yield return new RmvToGltfResolvedComponent(node.ModelAsset, string.Empty); + + if (node.ResolvedModelReference != null) + { + foreach (var component in EnumerateComponents(node.ResolvedModelReference)) + yield return component; + } + + foreach (var slot in node.Slots) + { + if (slot.SelectedChild == null) + continue; + + foreach (var component in EnumerateComponents(slot.SelectedChild, slot.AttachmentPoint)) + yield return component; + } + } + + private static IEnumerable EnumerateComponents( + ResolvedVariantMeshNode node, + string attachmentPoint) + { + if (node.ModelAsset != null) + yield return new RmvToGltfResolvedComponent(node.ModelAsset, attachmentPoint); + + if (node.ResolvedModelReference != null) + { + foreach (var component in EnumerateComponents(node.ResolvedModelReference, attachmentPoint)) + yield return component; + } + + foreach (var slot in node.Slots) + { + if (slot.SelectedChild == null) + continue; + + foreach (var component in EnumerateComponents(slot.SelectedChild, slot.AttachmentPoint)) + yield return component; + } + } + + /// + /// Selects the first non-empty component skeleton, matching the composed + /// exporter. Different component skeletons are reported to the caller; + /// they must not silently change the animation source. + /// + internal static string? SelectSharedSkeletonName( + IEnumerable assets, + ICollection? diagnostics = null) + { + var names = assets + .Select(x => x.Model.Header.SkeletonName) + .Where(x => string.IsNullOrWhiteSpace(x) == false) + .ToList(); + var selected = names.FirstOrDefault(); + if (string.IsNullOrWhiteSpace(selected)) + return null; + + foreach (var other in names.Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (string.Equals(other, selected, StringComparison.OrdinalIgnoreCase)) + continue; + + diagnostics?.Add( + $"Composed models use different skeletons ('{selected}' and '{other}'); " + + $"using '{selected}' for the shared glTF skeleton."); + break; + } + + return selected; + } + + private static bool IsVariantMeshDefinition(PackFile file) + => file.Name.EndsWith(".variantmeshdefinition", StringComparison.OrdinalIgnoreCase); + + private sealed class RmvToGltfAnimationSource + { + public RmvToGltfAnimationSource(string? skeletonName, IReadOnlyList diagnostics) + { + SkeletonName = skeletonName; + Diagnostics = diagnostics; + } + + public string? SkeletonName { get; } + public IReadOnlyList Diagnostics { get; } + } +} + +internal sealed record RmvToGltfResolvedComponent( + ResolvedModelAsset Asset, + string AttachmentPoint); diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporter.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporter.cs index df5651e1f..173466b6c 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporter.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporter.cs @@ -11,7 +11,13 @@ namespace Editors.ImportExport.Exporting.Exporters.RmvToGltf { - public class RmvToGltfExporter + internal interface IRmvToGltfExporter + { + ExportSupportEnum CanExportFile(PackFile file); + void Export(RmvToGltfExporterSettings settings); + } + + public class RmvToGltfExporter : IRmvToGltfExporter { private readonly ILogger _logger = Logging.Create(); private readonly IGltfSceneSaver _gltfSaver; @@ -74,6 +80,10 @@ internal ExportSupportEnum CanExportFile(PackFile file) return ExportSupportEnum.NotSupported; } + ExportSupportEnum IRmvToGltfExporter.CanExportFile(PackFile file) => CanExportFile(file); + + void IRmvToGltfExporter.Export(RmvToGltfExporterSettings settings) => Export(settings); + public void Export(RmvToGltfExporterSettings settings) { LogSettings(settings); @@ -185,23 +195,15 @@ private void ExportVariantMesh(RmvToGltfExporterSettings settings) { skeletonFile = null; exportCancelled = false; - var skeletonName = modelParts - .Select(x => x.Asset.Model.Header.SkeletonName) - .FirstOrDefault(x => string.IsNullOrWhiteSpace(x) == false); + var skeletonDiagnostics = new List(); + var skeletonName = GltfAnimationCatalogResolver.SelectSharedSkeletonName( + modelParts.Select(x => x.Asset), + skeletonDiagnostics); if (string.IsNullOrWhiteSpace(skeletonName)) return null; - foreach (var otherSkeletonName in modelParts - .Select(x => x.Asset.Model.Header.SkeletonName) - .Where(x => string.IsNullOrWhiteSpace(x) == false) - .Distinct(StringComparer.OrdinalIgnoreCase)) - { - if (!string.Equals(otherSkeletonName, skeletonName, StringComparison.OrdinalIgnoreCase)) - { - _logger.Here().Warning($"Composed models use different skeletons ('{skeletonName}' and '{otherSkeletonName}'); using '{skeletonName}' for the shared glTF skeleton."); - break; - } - } + foreach (var diagnostic in skeletonDiagnostics) + _logger.Here().Warning(diagnostic); skeletonFile = _skeletonLookUpHelper.GetSkeletonFileFromName(skeletonName); if (skeletonFile == null) @@ -227,38 +229,16 @@ private List FlattenModelParts(ResolvedVariantMeshNode node) { var output = new List(); var nextIndex = 0; - AppendModelParts(node, string.Empty, ref nextIndex, output); - return output; - } - - private void AppendModelParts( - ResolvedVariantMeshNode node, - string attachmentPoint, - ref int nextIndex, - List output) - { - if (node.ModelAsset != null) + foreach (var component in GltfAnimationCatalogResolver.EnumerateComponents(node)) { output.Add(new ExportModelPart( - node.ModelAsset, - attachmentPoint, + component.Asset, + component.AttachmentPoint, $"vmd_part_{nextIndex++:D3}", false, true)); } - - if (node.ResolvedModelReference != null) - { - AppendModelParts(node.ResolvedModelReference, attachmentPoint, ref nextIndex, output); - } - - foreach (var slot in node.Slots) - { - if (slot.SelectedChild == null) - continue; - - AppendModelParts(slot.SelectedChild, slot.AttachmentPoint, ref nextIndex, output); - } + return output; } private List BuildMeshes( @@ -374,9 +354,8 @@ private List ApplySharedSkeletonCompatibility( if (skeleton == null) return modelParts; - var sharedSkeletonName = modelParts - .Select(x => x.Asset.Model.Header.SkeletonName) - .FirstOrDefault(x => string.IsNullOrWhiteSpace(x) == false); + var sharedSkeletonName = GltfAnimationCatalogResolver.SelectSharedSkeletonName( + modelParts.Select(x => x.Asset)); if (string.IsNullOrWhiteSpace(sharedSkeletonName)) return modelParts; diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/ExportWindow.xaml b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/ExportWindow.xaml index 54821b2a2..9ccbe96d4 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/ExportWindow.xaml +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/ExportWindow.xaml @@ -7,7 +7,7 @@ xmlns:loc="clr-namespace:Shared.Ui.Common;assembly=Shared.Ui" mc:Ignorable="d" Style="{StaticResource CustomWindowStyle}" - Title="{loc:Loc ExportWindow.Title}" Height="350" Width="600"> + Title="{loc:Loc ExportWindow.Title}" Height="550" MinHeight="450" Width="700" MinWidth="600"> diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/ExporterCoreViewModel.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/ExporterCoreViewModel.cs index 239665340..60269cc83 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/ExporterCoreViewModel.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/ExporterCoreViewModel.cs @@ -37,6 +37,9 @@ public ExporterCoreViewModel(IEnumerable exporterViewModels) public void Initialize(PackFile packFile) { _inputFile = packFile; + PossibleExporters.Clear(); + SelectedExporter = null; + foreach (var viewModel in _exporterViewModels) { var supported = viewModel.CanExportFile(packFile); @@ -44,6 +47,7 @@ public void Initialize(PackFile packFile) continue; PossibleExporters.Add(viewModel); + viewModel.Initialize(packFile); if(supported == ExportSupportEnum.HighPriority) SelectedExporter = viewModel; } diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/IExporterViewModel.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/IExporterViewModel.cs index 8f05d449f..c77b3eeb9 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/IExporterViewModel.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/IExporterViewModel.cs @@ -10,6 +10,13 @@ public interface IExporterViewModel string OutputExtension { get; } string OutputFilter => $"File ({OutputExtension})|*{OutputExtension}"; + /// + /// Gives an exporter view model the source file before its view is + /// displayed or Execute is called. Existing exporters do not need + /// source-specific setup, so the default implementation is a no-op. + /// + void Initialize(PackFile exportSource) { } + public void Execute(PackFile exportSource, string outputPath, bool generateImporter); public ExportSupportEnum CanExportFile(PackFile file); } diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfExporterView.xaml b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfExporterView.xaml index cf87fbe52..9e5e97127 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfExporterView.xaml +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfExporterView.xaml @@ -20,6 +20,10 @@ + + + + @@ -43,6 +47,31 @@ + + + + + + + + +