From 5b31bdc2be85f9720e953d21ede3eada2cbc2dcb Mon Sep 17 00:00:00 2001 From: Ben McChesney Date: Sun, 22 Mar 2026 12:54:34 -0700 Subject: [PATCH] Add Rmv->glTF static exporter & texture handling Add static RMV->glTF export support and expand texture/export tooling, UI and CI. Some parts of the render chain are rendered in separate non-destructive passes. Key changes: - Add new RmvToGltf static exporter pieces: GltfStaticMeshBuilder, GltfStaticExporter, view & viewmodel, AlphaMaskCombiner, MeshOptimizerStaticPose helper. - Enhance GltfMeshBuilder: optional skeleton flag, robust handling/normalization of skin weights, default bindings for degenerate cases, and safer vertex handling. - Extend GltfTextureHandler: new TextureResult/MaskTextureResult records, mask handling, alpha extraction, normal-map variants, displacement map export pipeline (offset/raw/16-bit options), Pfim-based DDS handling and integration with packfile service. - Update DI: register static exporter, GltfStaticMeshBuilder and new services. - DdsToNormalPngExporter: now outputs a raw PNG path ("_raw.png") and uses ImageSaveHandler; removed inline blue-normal conversion routine. - Add AlphaMaskCombiner utility to merge diffuse + mask into an RGBA PNG. - XAML: replace custom CachedTabControl with standard TabControl; add binding proxy resource, item style (drag/drop, middle-click close), and template cleanup/fixes for tab context menu and close button formatting. - CI: expand pr-test workflow to run many individual project tests. - Misc: small fixes (AnimationBinWh3 splice casting fix, csproj cleanup/added folders) and other editor additions (QuickExportPosedMeshCommand etc.). These changes improve exporter robustness (static and skinned meshes), provide richer texture export options (masks, displacement, normal variants), and integrate exporters into DI and test pipelines. --- .github/workflows/pr-test.yml | 22 +- AssetEditor/Views/MainWindow.xaml | 92 +-- .../AnimationBinWh3FileToXmlConverter.cs | 5 +- .../DependencyInjectionContainer.cs | 3 + .../Editors.ImportExport.csproj | 2 +- .../DdsToNormalPng/DdsToNormalPngExporter.cs | 72 +- .../RmvToGltf/Helpers/AlphaMaskCombiner.cs | 119 +++ .../RmvToGltf/Helpers/GltfMeshBuilder.cs | 92 ++- .../Helpers/GltfStaticMeshBuilder.cs | 203 +++++ .../RmvToGltf/Helpers/GltfTextureHandler.cs | 721 +++++++++++++++++- .../Helpers/MeshOptimizerStaticPose.cs | 176 +++++ .../Exporters/RmvToGltf/RmvToGltfExporter.cs | 7 +- .../RmvToGltf/RmvToGltfExporterSettings.cs | 13 +- .../RmvToGltf/RmvToGltfStaticExporter.cs | 77 ++ .../RmvToGltf/RmvToGltfExporterView.xaml | 4 +- .../RmvToGltfStaticExporterView.xaml | 61 ++ .../RmvToGltfStaticExporterView.xaml.cs | 12 + .../RmvToGltfStaticExporterViewModel.cs | 59 ++ .../Editors.ImportExport/TextureHelper.cs | 37 +- .../Core/MenuBarViews/MenuBarViewModel.cs | 5 +- .../Editors.KitbasherEditor.csproj | 3 +- .../UiCommands/QuickExportPosedMeshCommand.cs | 219 ++++++ .../Visualisation/MetaDataBuilder.cs | 23 +- 23 files changed, 1842 insertions(+), 185 deletions(-) create mode 100644 Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/AlphaMaskCombiner.cs create mode 100644 Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfStaticMeshBuilder.cs create mode 100644 Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/MeshOptimizerStaticPose.cs create mode 100644 Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfStaticExporter.cs create mode 100644 Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterView.xaml create mode 100644 Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterView.xaml.cs create mode 100644 Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterViewModel.cs create mode 100644 Editors/Kitbashing/KitbasherEditor/UiCommands/QuickExportPosedMeshCommand.cs diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index cee99d12a..e247cc682 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -26,5 +26,23 @@ jobs: - name: Build the project run: dotnet build AssetEditor\AssetEditor.csproj --no-restore - - name: Run all tests - run: dotnet test AssetEditor.sln --configuration Release --no-restore --verbosity normal \ No newline at end of file + - name: Run Shared.Core Tests + run: dotnet test Testing\Shared.Core.Test\Test.Shared.Core.csproj --no-restore --verbosity normal + + - name: Run GameWorld.Core Tests + run: dotnet test Testing\GameWorld.Core.Test\Test.GameWorld.Core.csproj --no-restore --verbosity normal + + - name: Run E2E Verification Tests + run: dotnet test Testing\E2EVerification\Test.E2EVerification.csproj --no-restore --verbosity normal + + - name: Run Editors.ImportExport Tests + run: dotnet test Editors\ImportExportEditor\Test.ImportExport\Test.ImportExport.csproj --no-restore --verbosity normal + + - name: Run Editors.SkeletonEditor Tests + run: dotnet test Editors\SkeletonEditor\Test.SkeletonEditor\Test.SkeletonEditor.csproj --no-restore --verbosity normal + + - name: Run Editors.AnimatioReTarget Tests + run: dotnet test Editors\AnimationReTarget\Test.AnimatioReTarget\Test.AnimatioReTarget.csproj --no-restore --verbosity normal + + - name: Run Editors.Kitbashing Tests + run: dotnet test Editors\Kitbashing\Test.KitbashEditor\Test.KitbashEditor.csproj --no-restore --verbosity normal diff --git a/AssetEditor/Views/MainWindow.xaml b/AssetEditor/Views/MainWindow.xaml index 58cc7cb39..2f657c76b 100644 --- a/AssetEditor/Views/MainWindow.xaml +++ b/AssetEditor/Views/MainWindow.xaml @@ -158,54 +158,28 @@ - + Name="EditorsTabControl" BorderThickness="1, 1, 0, 0" > - - - - - - - - + + + - - + + + - - - - - - - - - - - - - - - - - - + @@ -219,12 +193,22 @@ - + - - - - + + + + @@ -240,12 +224,18 @@ - - - + + diff --git a/Editors/AnimationFragmentEditor/Editor.AnimationFragmentEditor/AnimationPack/Converters/AnimationBinWh3Converter/AnimationBinWh3FileToXmlConverter.cs b/Editors/AnimationFragmentEditor/Editor.AnimationFragmentEditor/AnimationPack/Converters/AnimationBinWh3Converter/AnimationBinWh3FileToXmlConverter.cs index 6133a9d3f..e5252d5c1 100644 --- a/Editors/AnimationFragmentEditor/Editor.AnimationFragmentEditor/AnimationPack/Converters/AnimationBinWh3Converter/AnimationBinWh3FileToXmlConverter.cs +++ b/Editors/AnimationFragmentEditor/Editor.AnimationFragmentEditor/AnimationPack/Converters/AnimationBinWh3Converter/AnimationBinWh3FileToXmlConverter.cs @@ -1,4 +1,4 @@ -using CommonControls.BaseDialogs.ErrorListDialog; +using CommonControls.BaseDialogs.ErrorListDialog; using Editors.Shared.Core.Editors.TextEditor; using GameWorld.Core.Services; using Shared.Core.ErrorHandling; @@ -301,8 +301,9 @@ private bool CheckForAnimationVersionsInMeta(string mainAnimationFile, string me foreach (var item in metaItems) { - if (item.DisplayName.Contains("SPLICE") && item is Splice_v11 splice) + if (item.DisplayName.Contains("SPLICE")) { + var splice = (Splice_v11)item; var animPath = splice.Animation; if (animPath == null || animPath == "") { diff --git a/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs b/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs index eff9883f9..5951f30bc 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs @@ -33,12 +33,14 @@ public override void Register(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // Exporters services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // Importer ViewModels RegisterWindow(services); @@ -60,6 +62,7 @@ public override void Register(IServiceCollection services) services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Editors.ImportExport.csproj b/Editors/ImportExportEditor/Editors.ImportExport/Editors.ImportExport.csproj index 518dfa331..d5bbd85f0 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Editors.ImportExport.csproj +++ b/Editors/ImportExportEditor/Editors.ImportExport/Editors.ImportExport.csproj @@ -1,4 +1,4 @@ - + net10.0-windows diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/DdsToNormalPng/DdsToNormalPngExporter.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/DdsToNormalPng/DdsToNormalPngExporter.cs index dafbf1cf2..3ac22fe67 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/DdsToNormalPng/DdsToNormalPngExporter.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/DdsToNormalPng/DdsToNormalPngExporter.cs @@ -1,4 +1,4 @@ -using System.Drawing; +using System.Drawing; using System.IO; using System.Numerics; using Editors.ImportExport.Misc; @@ -43,7 +43,7 @@ public string Export(string filePath, string outputPath, bool convertToBlueNorma var fileName = Path.GetFileNameWithoutExtension(filePath); var outDirectory = Path.GetDirectoryName(outputPath); - var fullFilePath = outDirectory + "/" + fileName + ".png"; + var rawFilePath = outDirectory + "/" + fileName + "_raw.png"; var bytes = packFile.DataSource.ReadData(); if (bytes == null || !bytes.Any()) @@ -53,73 +53,9 @@ public string Export(string filePath, string outputPath, bool convertToBlueNorma if (imgBytes == null || !imgBytes.Any()) throw new Exception($"image data invalid/empty. imgBytes.Count = {imgBytes?.Length}"); - if (convertToBlueNormalMap) - imgBytes = ConvertToBlueNormalMap(imgBytes, fullFilePath); + _imageSaveHandler.Save(imgBytes, rawFilePath); - _imageSaveHandler.Save(imgBytes, fullFilePath); - return fullFilePath; + return rawFilePath; } - - - private byte[] ConvertToBlueNormalMap(byte[] imgBytes, string fileDirectory) - { - var inMs = new MemoryStream(imgBytes); - using Image inImg = Image.FromStream(inMs); - - using Bitmap bitmap = new Bitmap(inImg); - { - for (int x = 0; x < bitmap.Width; x++) - { - for (int y = 0; y < bitmap.Height; y++) - { - // get pixel from orange map - var orangeMapRawPixel = bitmap.GetPixel(x, y); - - // convert bytes to float to interval [0; 1] - Vector4 orangeMapVector = new Vector4() - { - X = (float)orangeMapRawPixel.R / 255.0f, - Y = (float)orangeMapRawPixel.G / 255.0f, - Z = (float)orangeMapRawPixel.B / 255.0f, - W = (float)orangeMapRawPixel.A / 255.0f, - }; - - // fill blue map pixels - Vector3 blueMapPixel = new Vector3() - { - X = orangeMapVector.X * orangeMapVector.W, - Y = orangeMapVector.Y, - Z = 0 - }; - - // scale bluemap into interval [-1; 1] - blueMapPixel *= 2.0f; - blueMapPixel -= new Vector3(1, 1, 1); - - - // calculte z, using an orthogonal projection - blueMapPixel.Z = (float)Math.Sqrt(1.0f - blueMapPixel.X * blueMapPixel.X - blueMapPixel.Y * blueMapPixel.Y); - - - // convert the float values back to bytes, interval [0; 255] - var newColor = Color.FromArgb( - 255, - (byte)((blueMapPixel.X + 1.0f) * 0.5f * 255.0f), - (byte)((blueMapPixel.Y + 1.0f) * 0.5f * 255.0f), - (byte)((blueMapPixel.Z + 1.0f) * 0.5f * 255.0f) - ); - - bitmap.SetPixel(x, y, newColor); - } - } - - // get raw PNG bytes - using var b = new MemoryStream(); - bitmap.Save(b, System.Drawing.Imaging.ImageFormat.Png); - - return b.ToArray(); - } - } - } } diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/AlphaMaskCombiner.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/AlphaMaskCombiner.cs new file mode 100644 index 000000000..ab61d8386 --- /dev/null +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/AlphaMaskCombiner.cs @@ -0,0 +1,119 @@ +using System.Drawing; +using System.Drawing.Imaging; +using System.IO; +using Pfim; + +namespace Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers +{ + /// + /// Combines a mask texture with a diffuse texture to create a single RGBA texture + /// where the mask becomes the alpha channel. + /// + public class AlphaMaskCombiner + { + /// + /// Combines a diffuse (RGB) and mask (Grayscale) into a single RGBA texture. + /// + public static byte[] CombineDiffuseWithMask(byte[] diffuseDdsBytes, byte[] maskDdsBytes) + { + try + { + // Convert both DDS to bitmaps + var diffuseBitmap = ConvertDdsToBitmap(diffuseDdsBytes); + var maskBitmap = ConvertDdsToBitmap(maskDdsBytes); + + // Ensure same dimensions + if (diffuseBitmap.Width != maskBitmap.Width || diffuseBitmap.Height != maskBitmap.Height) + { + throw new InvalidOperationException( + $"Diffuse and mask textures have different dimensions: " + + $"diffuse {diffuseBitmap.Width}x{diffuseBitmap.Height}, " + + $"mask {maskBitmap.Width}x{maskBitmap.Height}"); + } + + // Create RGBA bitmap + using var combinedBitmap = new Bitmap(diffuseBitmap.Width, diffuseBitmap.Height, PixelFormat.Format32bppArgb); + + // Combine pixels + for (int x = 0; x < diffuseBitmap.Width; x++) + { + for (int y = 0; y < diffuseBitmap.Height; y++) + { + var diffusePixel = diffuseBitmap.GetPixel(x, y); + var maskPixel = maskBitmap.GetPixel(x, y); + + // Use mask's grayscale value as alpha + int alpha = maskPixel.R; // Grayscale uses same value for R, G, B + var combinedPixel = Color.FromArgb(alpha, diffusePixel.R, diffusePixel.G, diffusePixel.B); + + combinedBitmap.SetPixel(x, y, combinedPixel); + } + } + + diffuseBitmap.Dispose(); + maskBitmap.Dispose(); + + // Export to PNG with alpha + using var pngStream = new MemoryStream(); + combinedBitmap.Save(pngStream, System.Drawing.Imaging.ImageFormat.Png); + return pngStream.ToArray(); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to combine diffuse and mask textures: {ex.Message}", ex); + } + } + + private static Bitmap ConvertDdsToBitmap(byte[] ddsBytes) + { + using var m = new MemoryStream(); + using var w = new BinaryWriter(m); + w.Write(ddsBytes); + m.Seek(0, SeekOrigin.Begin); + + var image = Pfimage.FromStream(m); + + // 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; + } + + PixelFormat pixelFormat = image.Format == Pfim.ImageFormat.Rgba32 + ? PixelFormat.Format32bppArgb + : PixelFormat.Format24bppRgb; + + 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); + bitmap.UnlockBits(bitmapData); + + return bitmap; + } + } +} 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 9e473d1b9..7eb485d4b 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfMeshBuilder.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfMeshBuilder.cs @@ -1,4 +1,4 @@ -using System.IO; +using System.IO; using System.Numerics; using Editors.ImportExport.Common; using Shared.GameFormats.RigidModel; @@ -12,10 +12,10 @@ namespace Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers { public class GltfMeshBuilder { - public List> Build(RmvFile rmv2, List textures, RmvToGltfExporterSettings settings) + public List> Build(RmvFile rmv2, List textures, RmvToGltfExporterSettings settings, bool willHaveSkeleton = true) { var lodLevel = rmv2.ModelList.First(); - var hasSkeleton = string.IsNullOrWhiteSpace(rmv2.Header.SkeletonName) == false; + var hasSkeleton = willHaveSkeleton && string.IsNullOrWhiteSpace(rmv2.Header.SkeletonName) == false; var meshes = new List>(); for(var i = 0; i < lodLevel.Length; i++) @@ -32,7 +32,9 @@ public List> Build(RmvFile rmv2, List GenerateMesh(RmvMesh rmvMesh, string modelName, MaterialBuilder material, bool hasSkeleton, bool doMirror) { var mesh = new MeshBuilder(modelName); - if (hasSkeleton) + // Only enable skinning validation if the model has a skeleton and this mesh actually contains weight data + var hasAnyWeights = rmvMesh.VertexList.Any(v => v.WeightCount > 0); + if (hasSkeleton && hasAnyWeights) mesh.VertexPreprocessor.SetValidationPreprocessors(); var prim = mesh.UsePrimitive(material); @@ -53,12 +55,25 @@ MeshBuilder Generate if (hasSkeleton) { - glTfvertex = SetVertexInfluences(vertex, glTfvertex); + if (vertex.WeightCount > 0) + { + glTfvertex = SetVertexInfluences(vertex, glTfvertex); + } + else if (hasAnyWeights) + { + // If some vertices have weights in this mesh we enabled validation. + // Ensure vertices without weights get a default binding so validation passes. + glTfvertex.Skinning.SetBindings((0, 1), (0, 0), (0, 0), (0, 0)); + } } - else + else if (hasAnyWeights) { + // Model has weight data but no skeleton is available. + // Set default binding to prevent validation errors. glTfvertex.Skinning.SetBindings((0, 1), (0, 0), (0, 0), (0, 0)); } + + // For static meshes or vertices handled above, add the vertex vertexList.Add(glTfvertex); } @@ -88,28 +103,63 @@ MeshBuilder Generate VertexBuilder SetVertexInfluences(CommonVertex vertex, VertexBuilder glTfvertex) { - if (vertex.WeightCount == 2) + // Support 1,2,3,4 weight counts and normalize/handle degenerate cases so SharpGLTF validation won't fail. + var weights = new float[4]; + var indices = new int[4]; + + var count = Math.Clamp(vertex.WeightCount, 0, 4); + for (int i = 0; i < count; ++i) + { + indices[i] = vertex.BoneIndex[i]; + weights[i] = vertex.BoneWeight[i]; + // guard against negative weights from malformed data + if (weights[i] < 0) weights[i] = 0f; + } + + // If there are fewer than 4 influences, remaining indices default to 0 and weights to 0 + for (int i = count; i < 4; ++i) { - var rigging = new (int, float)[2] { - (vertex.BoneIndex[0], vertex.BoneWeight[0]), - (vertex.BoneIndex[1], 1.0f - vertex.BoneWeight[0]) - }; + indices[i] = 0; + weights[i] = 0f; + } - glTfvertex.Skinning.SetBindings(rigging); + float sum = weights[0] + weights[1] + weights[2] + weights[3]; + if (sum <= float.Epsilon) + { + // Degenerate: no meaningful weights. Fall back to binding to the first available bone or to bone 0. + if (count > 0) + { + indices[0] = vertex.BoneIndex[0]; + weights[0] = 1f; + weights[1] = weights[2] = weights[3] = 0f; + } + else + { + indices[0] = 0; + weights[0] = 1f; + weights[1] = weights[2] = weights[3] = 0f; + } } - else if (vertex.WeightCount == 4) + else { - var rigging = new (int, float)[4] { - (vertex.BoneIndex[0], vertex.BoneWeight[0]), - (vertex.BoneIndex[1], vertex.BoneWeight[1]), - (vertex.BoneIndex[2], vertex.BoneWeight[2]), - (vertex.BoneIndex[3], 1.0f - (vertex.BoneWeight[0] + vertex.BoneWeight[1] + vertex.BoneWeight[2])) - }; - - glTfvertex.Skinning.SetBindings(rigging); + // Normalize weights so they sum to 1 + weights[0] /= sum; + weights[1] /= sum; + weights[2] /= sum; + weights[3] /= sum; } + var rigging = new (int, float)[4] + { + (indices[0], weights[0]), + (indices[1], weights[1]), + (indices[2], weights[2]), + (indices[3], weights[3]) + }; + + glTfvertex.Skinning.SetBindings(rigging); + return glTfvertex; } diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfStaticMeshBuilder.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfStaticMeshBuilder.cs new file mode 100644 index 000000000..8be7407b8 --- /dev/null +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfStaticMeshBuilder.cs @@ -0,0 +1,203 @@ +using System.IO; +using System.Numerics; +using Editors.ImportExport.Common; +using Shared.GameFormats.RigidModel; +using SharpGLTF.Geometry; +using SharpGLTF.Geometry.VertexTypes; +using SharpGLTF.Materials; +using AlphaMode = SharpGLTF.Materials.AlphaMode; + +namespace Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers +{ + public class GltfStaticMeshBuilder + { + public List> Build(RmvFile rmv2, List textures, RmvToGltfExporterSettings settings) + { + var lodLevel = rmv2.ModelList.First(); + + var meshes = new List>(); + for (var i = 0; i < lodLevel.Length; i++) + { + var rmvMesh = lodLevel[i]; + var meshTextures = textures.Where(x => x.MeshIndex == i).ToList(); + var gltfMaterial = Create(settings, rmvMesh.Material.ModelName + "_Material", meshTextures); + var gltfMesh = GenerateStaticMesh(rmvMesh.Mesh, rmvMesh.Material.ModelName, gltfMaterial, settings.MirrorMesh); + meshes.Add(gltfMesh); + } + return meshes; + } + + MeshBuilder GenerateStaticMesh(RmvMesh rmvMesh, string modelName, MaterialBuilder material, bool doMirror) + { + var mesh = new MeshBuilder(modelName); + var prim = mesh.UsePrimitive(material); + + var vertexList = new List>(); + foreach (var vertex in rmvMesh.VertexList) + { + var glTfvertex = new VertexBuilder(); + glTfvertex.Geometry.Position = new Vector3(vertex.Position.X, vertex.Position.Y, vertex.Position.Z); + glTfvertex.Geometry.Normal = new Vector3(vertex.Normal.X, vertex.Normal.Y, vertex.Normal.Z); + glTfvertex.Geometry.Tangent = new Vector4(vertex.Tangent.X, vertex.Tangent.Y, vertex.Tangent.Z, 1); + glTfvertex.Material.TexCoord = new Vector2(vertex.Uv.X, vertex.Uv.Y); + + // Apply geometric transformations + glTfvertex.Geometry.Position = VecConv.GetSys(GlobalSceneTransforms.FlipVector(VecConv.GetXna(glTfvertex.Geometry.Position), doMirror)); + glTfvertex.Geometry.Normal = VecConv.GetSys(GlobalSceneTransforms.FlipVector(VecConv.GetXna(glTfvertex.Geometry.Normal), doMirror)); + glTfvertex.Geometry.Tangent = VecConv.GetSys(GlobalSceneTransforms.FlipVector(VecConv.GetXna(glTfvertex.Geometry.Tangent), doMirror)); + + // Validate and fix normals and tangents for quality + glTfvertex.Geometry.Normal = ValidateAndFixNormal(glTfvertex.Geometry.Normal); + glTfvertex.Geometry.Tangent = ValidateAndFixTangent(glTfvertex.Geometry.Tangent, glTfvertex.Geometry.Normal); + + vertexList.Add(glTfvertex); + } + + var triangleCount = rmvMesh.IndexList.Length; + for (var i = 0; i < triangleCount; i += 3) + { + ushort i0, i1, i2; + if (doMirror) // if mirrored, flip the winding order + { + i0 = rmvMesh.IndexList[i + 0]; + i1 = rmvMesh.IndexList[i + 2]; + i2 = rmvMesh.IndexList[i + 1]; + } + else + { + i0 = rmvMesh.IndexList[i + 0]; + i1 = rmvMesh.IndexList[i + 1]; + i2 = rmvMesh.IndexList[i + 2]; + } + + prim.AddTriangle(vertexList[i0], vertexList[i1], vertexList[i2]); + } + return mesh; + } + + Vector3 ValidateAndFixNormal(Vector3 normal) + { + float lengthSquared = normal.LengthSquared(); + + // Check for zero or near-zero length normals + if (lengthSquared < 0.0001f) + { + // Return a safe default normal pointing up + return new Vector3(0, 0, 1); + } + + // Check if normalization is needed (tolerance for floating point precision) + if (Math.Abs(lengthSquared - 1.0f) > 0.001f) + { + return Vector3.Normalize(normal); + } + + return normal; + } + + Vector4 ValidateAndFixTangent(Vector4 tangent, Vector3 normal) + { + var tangentXYZ = new Vector3(tangent.X, tangent.Y, tangent.Z); + float lengthSquared = tangentXYZ.LengthSquared(); + + // Check for zero or near-zero length tangents - generate a perpendicular vector + if (lengthSquared < 0.0001f) + { + tangentXYZ = GeneratePerpendicularVector(normal); + return new Vector4(tangentXYZ.X, tangentXYZ.Y, tangentXYZ.Z, 1); + } + + // Normalize tangent if needed + if (Math.Abs(lengthSquared - 1.0f) > 0.001f) + { + tangentXYZ = Vector3.Normalize(tangentXYZ); + } + + // Ensure tangent handedness is valid (W should be ±1, typically 1 for right-handed) + float handedness = tangent.W; + if (Math.Abs(handedness) < 0.5f) + { + handedness = 1.0f; + } + else + { + handedness = handedness > 0 ? 1.0f : -1.0f; + } + + return new Vector4(tangentXYZ.X, tangentXYZ.Y, tangentXYZ.Z, handedness); + } + + Vector3 GeneratePerpendicularVector(Vector3 normal) + { + Vector3 tangent; + + // Choose axis that's most perpendicular to normal + if (Math.Abs(normal.X) > 0.9f) + { + tangent = Vector3.Cross(normal, new Vector3(0, 1, 0)); + } + else + { + tangent = Vector3.Cross(normal, new Vector3(1, 0, 0)); + } + + return Vector3.Normalize(tangent); + } + + MaterialBuilder Create(RmvToGltfExporterSettings settings, string materialName, List texturesForModel) + { + // Option 4: Material Enhancement with proper PBR setup + var material = new MaterialBuilder(materialName) + .WithDoubleSide(true) + .WithMetallicRoughness(); + + // Enhanced alpha detection for masked geometry (capes, fur, wings) + bool hasAlphaMaskedTexture = texturesForModel.Any(t => t.HasAlphaChannel); + bool hasMaskInName = texturesForModel.Any(t => + t.SystemFilePath.Contains("mask", StringComparison.OrdinalIgnoreCase) || + t.SystemFilePath.Contains("_m.", StringComparison.OrdinalIgnoreCase)); + bool hasTransparency = texturesForModel.Any(t => + t.SystemFilePath.Contains("alpha", StringComparison.OrdinalIgnoreCase) || + t.SystemFilePath.Contains("transparent", StringComparison.OrdinalIgnoreCase)); + + // Detect common alpha-masked mesh types + bool isAlphaMaskedMesh = materialName.Contains("cape", StringComparison.OrdinalIgnoreCase) || + materialName.Contains("fur", StringComparison.OrdinalIgnoreCase) || + materialName.Contains("wing", StringComparison.OrdinalIgnoreCase) || + materialName.Contains("feather", StringComparison.OrdinalIgnoreCase) || + materialName.Contains("hair", StringComparison.OrdinalIgnoreCase) || + materialName.Contains("foliage", StringComparison.OrdinalIgnoreCase) || + materialName.Contains("leaf", StringComparison.OrdinalIgnoreCase) || + materialName.Contains("chain", StringComparison.OrdinalIgnoreCase); + + // Set appropriate alpha mode + if (hasTransparency) + { + material.WithAlpha(AlphaMode.BLEND); + } + else if (hasAlphaMaskedTexture || hasMaskInName || isAlphaMaskedMesh) + { + // Use MASK mode with alpha cutoff for sharp edges (better for fur/capes) + material.WithAlpha(AlphaMode.MASK, 0.5f); + } + else + { + material.WithAlpha(AlphaMode.OPAQUE); + } + + foreach (var texture in texturesForModel) + { + material.WithChannelImage(texture.GlftTexureType, texture.SystemFilePath); + + var channel = material.UseChannel(texture.GlftTexureType); + if (channel?.Texture?.PrimaryImage != null) + { + // Set SharpGLTF to re-resave textures with specified paths + channel.Texture.PrimaryImage.AlternateWriteFileName = Path.GetFileName(texture.SystemFilePath); + } + } + + return material; + } + } +} 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 0034d7e9f..564f4cddc 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs @@ -1,12 +1,15 @@ -using Editors.ImportExport.Exporting.Exporters.DdsToMaterialPng; +using System.IO; +using Editors.ImportExport.Exporting.Exporters.DdsToMaterialPng; using Editors.ImportExport.Exporting.Exporters.DdsToNormalPng; using Shared.GameFormats.RigidModel; using Shared.GameFormats.RigidModel.Types; +using Shared.Core.PackFiles; using SharpGLTF.Materials; namespace Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers { - public record TextureResult(int MeshIndex, string SystemFilePath, KnownChannel GlftTexureType); + public record TextureResult(int MeshIndex, string SystemFilePath, KnownChannel GlftTexureType, bool HasAlphaChannel = false); + public record MaskTextureResult(int MeshIndex, string SystemFilePath); public interface IGltfTextureHandler { @@ -17,11 +20,14 @@ public class GltfTextureHandler : IGltfTextureHandler { private readonly IDdsToNormalPngExporter _ddsToNormalPngExporter; private readonly IDdsToMaterialPngExporter _ddsToMaterialPngExporter; + private readonly IPackFileService _packFileService; - public GltfTextureHandler(IDdsToNormalPngExporter ddsToNormalPngExporter, IDdsToMaterialPngExporter ddsToMaterialPngExporter) + public GltfTextureHandler(IDdsToNormalPngExporter ddsToNormalPngExporter, IDdsToMaterialPngExporter ddsToMaterialPngExporter, IPackFileService packFileService = null) { _ddsToNormalPngExporter = ddsToNormalPngExporter; _ddsToMaterialPngExporter = ddsToMaterialPngExporter; + + _packFileService = packFileService; } public List HandleTextures(RmvFile rmvFile, RmvToGltfExporterSettings settings) @@ -47,19 +53,25 @@ public List HandleTextures(RmvFile rmvFile, RmvToGltfExporterSett { case TextureType.Normal: DoTextureConversionNormalMap(settings, output, exportedTextures, meshIndex, tex); break; case TextureType.MaterialMap: DoTextureConversionMaterialMap(settings, output, exportedTextures, meshIndex, tex); break; - case TextureType.BaseColour: DoTextureDefault(KnownChannel.BaseColor, settings, output, exportedTextures, meshIndex, tex); break; - case TextureType.Diffuse: DoTextureDefault(KnownChannel.BaseColor, 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; } } - } + } + - } return output; - } + } interface IDDsToPngExporter { public string Export(string path, string outputPath, bool convertToBlender) @@ -91,21 +103,708 @@ private void DoTextureConversionMaterialMap(RmvToGltfExporterSettings settings, private void DoTextureDefault(KnownChannel textureType, RmvToGltfExporterSettings settings, List output, Dictionary exportedTextures, int meshIndex, MaterialBuilderTextureInput text) { if (exportedTextures.ContainsKey(text.Path) == false) - exportedTextures[text.Path] = _ddsToMaterialPngExporter.Export(text.Path, settings.OutputPath, false); // TODO: exchange export with a default one + { + exportedTextures[text.Path] = _ddsToMaterialPngExporter.Export(text.Path, settings.OutputPath, false); + + // 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); + } + } var systemPath = exportedTextures[text.Path]; if (systemPath != null) - output.Add(new TextureResult(meshIndex, systemPath, textureType)); + output.Add(new TextureResult(meshIndex, systemPath, textureType, false)); + } + + private void DoTextureMask(RmvToGltfExporterSettings settings, List output, Dictionary exportedTextures, int meshIndex, MaterialBuilderTextureInput text) + { + if (exportedTextures.ContainsKey(text.Path) == false) + { + // Export mask as separate PNG - name it with _mask suffix for clarity + var exportedPath = _ddsToMaterialPngExporter.Export(text.Path, settings.OutputPath, false); + + if (exportedPath != null) + { + // Invert the mask values for proper alpha channel usage + // Game masks are often inverted (black=show, white=hide) + // Alpha channels need (black=transparent, white=opaque) + InvertMaskImage(exportedPath); + + // Rename to have _mask suffix + var directory = Path.GetDirectoryName(exportedPath); + var fileNameWithoutExt = Path.GetFileNameWithoutExtension(exportedPath); + var newFileName = fileNameWithoutExt + "_mask.png"; + var newPath = Path.Combine(directory, newFileName); + + if (File.Exists(exportedPath)) + { + if (File.Exists(newPath)) + File.Delete(newPath); + File.Move(exportedPath, newPath); + exportedPath = newPath; + } + } + + exportedTextures[text.Path] = exportedPath; + } + + var systemPath = exportedTextures[text.Path]; + if (systemPath != null) + { + // Export mask as a regular texture - user will connect it manually in Blender + // We'll add it as a separate texture that doesn't get auto-connected but is available + output.Add(new TextureResult(meshIndex, systemPath, KnownChannel.BaseColor, false)); + } + } + + private void InvertMaskImage(string imagePath) + { + byte[] imageBytes; + + // Load image into memory to avoid file lock + using (var fs = File.OpenRead(imagePath)) + using (var ms = new MemoryStream()) + { + fs.CopyTo(ms); + imageBytes = ms.ToArray(); + } + + // Process the image from memory + using var imageStream = new MemoryStream(imageBytes); + using var image = System.Drawing.Image.FromStream(imageStream); + using var bitmap = new System.Drawing.Bitmap(image); + + // Invert all pixel values (255 - value) + for (int x = 0; x < bitmap.Width; x++) + { + for (int y = 0; y < bitmap.Height; y++) + { + var pixel = bitmap.GetPixel(x, y); + var invertedR = 255 - pixel.R; + var invertedG = 255 - pixel.G; + var invertedB = 255 - pixel.B; + var invertedColor = System.Drawing.Color.FromArgb(pixel.A, invertedR, invertedG, invertedB); + bitmap.SetPixel(x, y, invertedColor); + } + } + + // Save back to the same file + bitmap.Save(imagePath, System.Drawing.Imaging.ImageFormat.Png); } private void DoTextureConversionNormalMap(RmvToGltfExporterSettings settings, List output, Dictionary exportedTextures, int meshIndex, MaterialBuilderTextureInput text) { if (exportedTextures.ContainsKey(text.Path) == false) - exportedTextures[text.Path] = _ddsToNormalPngExporter.Export(text.Path, settings.OutputPath, settings.ConvertNormalTextureToBlue); + { + // 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); + + // 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"); + } + else + { + // Regular export: use the standard DDS to PNG exporter + exportedTextures[text.Path] = _ddsToNormalPngExporter.Export(text.Path, settings.OutputPath, settings.ConvertNormalTextureToBlue); + } + } var systemPath = exportedTextures[text.Path]; if (systemPath != null) output.Add(new TextureResult(meshIndex, systemPath, KnownChannel.Normal)); } + + private void ExportNormalMapVariants(string packFilePath, string outputPath) + { + if (_packFileService == null) + return; + + var packFile = _packFileService.FindFile(packFilePath); + if (packFile == null) + return; + + var fileName = Path.GetFileNameWithoutExtension(packFilePath); + var outDirectory = Path.GetDirectoryName(outputPath); + + var bytes = packFile.DataSource.ReadData(); + if (bytes != null && bytes.Any()) + { + ExportRawNormalMapPng(bytes, outDirectory, fileName); + ExportOffsetNormalMapPng(bytes, outDirectory, fileName); + } + } + + private void ExportAlphaMask(string packFilePath, string outputPath) + { + if (_packFileService == null) + return; + + var packFile = _packFileService.FindFile(packFilePath); + if (packFile == null) + return; + + var fileName = Path.GetFileNameWithoutExtension(packFilePath); + var outDirectory = Path.GetDirectoryName(outputPath); + + var bytes = packFile.DataSource.ReadData(); + if (bytes == null || !bytes.Any()) + return; + + // Convert DDS to bitmap + using var m = new MemoryStream(); + using var w = new BinaryWriter(m); + w.Write(bytes); + m.Seek(0, SeekOrigin.Begin); + + var image = Pfim.Pfimage.FromStream(m); + + if (image.Format != Pfim.ImageFormat.Rgba32) + return; // No alpha channel + + using var sourceBitmap = new System.Drawing.Bitmap(image.Width, image.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb); + + var bitmapData = sourceBitmap.LockBits( + new System.Drawing.Rectangle(0, 0, image.Width, image.Height), + System.Drawing.Imaging.ImageLockMode.WriteOnly, + System.Drawing.Imaging.PixelFormat.Format32bppArgb); + + System.Runtime.InteropServices.Marshal.Copy(image.Data, 0, bitmapData.Scan0, image.DataLen); + sourceBitmap.UnlockBits(bitmapData); + + // Extract alpha channel as black and white mask + using var maskBitmap = new System.Drawing.Bitmap(image.Width, image.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb); + + for (int y = 0; y < image.Height; y++) + { + for (int x = 0; x < image.Width; x++) + { + var pixel = sourceBitmap.GetPixel(x, y); + byte alpha = pixel.A; + + // Create grayscale mask from alpha channel + // White = opaque (alpha 255), Black = transparent (alpha 0) + maskBitmap.SetPixel(x, y, System.Drawing.Color.FromArgb(255, alpha, alpha, alpha)); + } + } + + var maskPath = Path.Combine(outDirectory, fileName + "_alphamask.png"); + maskBitmap.Save(maskPath, System.Drawing.Imaging.ImageFormat.Png); + } + + public void ExportDisplacementFromNormalMap(string normalMapPath, string outputPath, RmvToGltfExporterSettings settings) + { + var fileName = Path.GetFileNameWithoutExtension(normalMapPath); + var outDirectory = Path.GetDirectoryName(outputPath); + + if (_packFileService == null) + return; + + var packFile = _packFileService.FindFile(normalMapPath); + if (packFile == null) + return; + + var bytes = packFile.DataSource.ReadData(); + if (bytes != null && bytes.Any()) + { + ExportDisplacementMapPng(bytes, outDirectory, fileName, settings); + } + } + + private void ExportRawNormalMapPng(byte[] ddsBytes, string outDirectory, string fileName) + { + using var m = new MemoryStream(); + using var w = new BinaryWriter(m); + w.Write(ddsBytes); + m.Seek(0, SeekOrigin.Begin); + + var image = Pfim.Pfimage.FromStream(m); + + var pixelFormat = System.Drawing.Imaging.PixelFormat.Format32bppArgb; + if (image.Format == Pfim.ImageFormat.Rgba32) + { + pixelFormat = System.Drawing.Imaging.PixelFormat.Format32bppArgb; + } + else if (image.Format == Pfim.ImageFormat.Rgb24) + { + pixelFormat = System.Drawing.Imaging.PixelFormat.Format24bppRgb; + } + else + { + return; + } + + using var rawBitmap = new System.Drawing.Bitmap(image.Width, image.Height, pixelFormat); + + var bitmapData = rawBitmap.LockBits( + new System.Drawing.Rectangle(0, 0, image.Width, image.Height), + System.Drawing.Imaging.ImageLockMode.WriteOnly, + pixelFormat); + + System.Runtime.InteropServices.Marshal.Copy(image.Data, 0, bitmapData.Scan0, image.DataLen); + rawBitmap.UnlockBits(bitmapData); + + var rawPngPath = Path.Combine(outDirectory, fileName + "_raw.png"); + rawBitmap.Save(rawPngPath, System.Drawing.Imaging.ImageFormat.Png); + } + + private void ExportOffsetNormalMapPng(byte[] ddsBytes, string outDirectory, string fileName) + { + var rawPngPath = Path.Combine(outDirectory, fileName + "_raw.png"); + + if (!File.Exists(rawPngPath)) + return; + + using var rawImage = System.Drawing.Image.FromFile(rawPngPath); + using var rawBitmap = new System.Drawing.Bitmap(rawImage); + using var outputBitmap = new System.Drawing.Bitmap(rawBitmap.Width, rawBitmap.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb); + + const int bgR = 128; + const int bgG = 128; + const int bgB = 255; + + // Manually composite pixel-by-pixel for proper alpha blending + for (int y = 0; y < rawBitmap.Height; y++) + { + for (int x = 0; x < rawBitmap.Width; x++) + { + var pixel = rawBitmap.GetPixel(x, y); + + // Note: Swap R and B because raw PNG is in BGRA format from Pfim + float alpha = pixel.A / 255.0f; + float invAlpha = 1.0f - alpha; + + int compositeR = (int)(pixel.B * alpha + bgR * invAlpha); // Use B for R + int compositeG = (int)(pixel.G * alpha + bgG * invAlpha); + int compositeB = (int)(pixel.R * alpha + bgB * invAlpha); // Use R for B + + compositeR = Math.Clamp(compositeR, 0, 255); + compositeG = Math.Clamp(compositeG, 0, 255); + compositeB = Math.Clamp(compositeB, 0, 255); + + outputBitmap.SetPixel(x, y, System.Drawing.Color.FromArgb(255, compositeR, compositeG, compositeB)); + } + } + + var offsetPngPath = Path.Combine(outDirectory, fileName + "_offset.png"); + outputBitmap.Save(offsetPngPath, System.Drawing.Imaging.ImageFormat.Png); + } + + private void ExportDisplacementMapPng(byte[] ddsBytes, string outDirectory, string fileName, RmvToGltfExporterSettings settings) + { + var offsetPngPath = Path.Combine(outDirectory, fileName + "_offset.png"); + + if (!File.Exists(offsetPngPath)) + return; + + using var offsetImage = System.Drawing.Image.FromFile(offsetPngPath); + using var offsetBitmap = new System.Drawing.Bitmap(offsetImage); + + int width = offsetBitmap.Width; + int height = offsetBitmap.Height; + + // Export standard displacement map (luminance + smoothing) + var standardHeightMap = StandardHeightMapGeneration(offsetBitmap, settings.DisplacementIterations); + ApplyContrast(standardHeightMap, settings.DisplacementContrast); + + if (settings.DisplacementSharpness > 0) + { + standardHeightMap = ApplyBilateralFilter(standardHeightMap, settings.DisplacementSharpness); + } + + NormalizeHeightMap(standardHeightMap, out float minHeight, out float maxHeight); + + if (settings.Export16BitDisplacement) + { + Save16BitDisplacementMap(standardHeightMap, outDirectory, fileName); + } + else + { + Save8BitDisplacementMap(standardHeightMap, outDirectory, fileName + "_displacement"); + } + + // Export Poisson reconstruction version for comparison (if enabled) + if (settings.UsePoissonReconstruction) + { + var poissonHeightMap = PoissonReconstruction(offsetBitmap, settings.DisplacementIterations); + ApplyContrast(poissonHeightMap, settings.DisplacementContrast); + + if (settings.DisplacementSharpness > 0) + { + poissonHeightMap = ApplyBilateralFilter(poissonHeightMap, settings.DisplacementSharpness); + } + + NormalizeHeightMap(poissonHeightMap, out float poissonMin, out float poissonMax); + + // Save Poisson as 8-bit for comparison + Save8BitDisplacementMap(poissonHeightMap, outDirectory, fileName + "_displacement_poisson"); + } + + // Export multi-scale version for comparison (if enabled) + if (settings.UseMultiScaleProcessing) + { + var multiScaleHeightMap = ProcessMultiScale(offsetBitmap, settings); + ApplyContrast(multiScaleHeightMap, settings.DisplacementContrast); + + if (settings.DisplacementSharpness > 0) + { + multiScaleHeightMap = ApplyBilateralFilter(multiScaleHeightMap, settings.DisplacementSharpness); + } + + NormalizeHeightMap(multiScaleHeightMap, out float multiMin, out float multiMax); + + // Save multi-scale as 8-bit for comparison + Save8BitDisplacementMap(multiScaleHeightMap, outDirectory, fileName + "_displacement_multiscale"); + } + } + + private float[,] StandardHeightMapGeneration(System.Drawing.Bitmap rawBitmap, int iterations) + { + int width = rawBitmap.Width; + int height = rawBitmap.Height; + float[,] heightMap = new float[width, height]; + + // Convert normal map to initial grayscale using luminance (matching NormalMap-Online approach) + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + var pixel = rawBitmap.GetPixel(x, y); + float gray = (pixel.R * 0.299f + pixel.G * 0.587f + pixel.B * 0.114f) / 255.0f; + heightMap[x, y] = gray; + } + } + + // Apply iterative smoothing (relaxation/diffusion) + float[,] tempMap = new float[width, height]; + for (int iter = 0; iter < iterations; iter++) + { + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + float sum = 0; + int count = 0; + + // Sample neighbors (above, left, right, below) + if (y > 0) { sum += heightMap[x, y - 1]; count++; } + if (x > 0) { sum += heightMap[x - 1, y]; count++; } + if (x < width - 1) { sum += heightMap[x + 1, y]; count++; } + if (y < height - 1) { sum += heightMap[x, y + 1]; count++; } + + tempMap[x, y] = count > 0 ? sum / count : heightMap[x, y]; + } + } + Array.Copy(tempMap, heightMap, width * height); + } + + return heightMap; + } + + private float[,] PoissonReconstruction(System.Drawing.Bitmap rawBitmap, int iterations) + { + int width = rawBitmap.Width; + int height = rawBitmap.Height; + + // Start with the same luminance-based initial height as standard method + float[,] heightMap = new float[width, height]; + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + var pixel = rawBitmap.GetPixel(x, y); + float gray = (pixel.R * 0.299f + pixel.G * 0.587f + pixel.B * 0.114f) / 255.0f; + heightMap[x, y] = gray; + } + } + + // Extract gradients from normal map for refinement + float[,] gradientX = new float[width, height]; + float[,] gradientY = new float[width, height]; + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + var pixel = rawBitmap.GetPixel(x, y); + // Convert from [0,255] to [-1,1] - normal map encoding + gradientX[x, y] = (pixel.R / 255.0f) * 2.0f - 1.0f; + gradientY[x, y] = (pixel.G / 255.0f) * 2.0f - 1.0f; + } + } + + // Solve Poisson equation using Jacobi iteration + // Use fewer iterations and dampen the gradient influence to avoid noise amplification + float[,] tempMap = new float[width, height]; + for (int iter = 0; iter < iterations; iter++) // Reduced from iterations * 5 + { + for (int y = 1; y < height - 1; y++) + { + for (int x = 1; x < width - 1; x++) + { + // Divergence of gradient field + float div = (gradientX[x, y] - gradientX[x - 1, y]) + + (gradientY[x, y] - gradientY[x, y - 1]); + + // Laplacian: average of neighbors + float laplacian = (heightMap[x - 1, y] + heightMap[x + 1, y] + + heightMap[x, y - 1] + heightMap[x, y + 1]) * 0.25f; + + // Dampen the divergence influence to reduce noise (0.1 instead of 0.25) + tempMap[x, y] = laplacian - div * 0.1f; + } + } + Array.Copy(tempMap, heightMap, width * height); + } + + // Normalize the Poisson result to 0-1 range before returning + float minVal = float.MaxValue; + float maxVal = float.MinValue; + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + minVal = Math.Min(minVal, heightMap[x, y]); + maxVal = Math.Max(maxVal, heightMap[x, y]); + } + } + + if (maxVal > minVal) + { + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + heightMap[x, y] = (heightMap[x, y] - minVal) / (maxVal - minVal); + } + } + } + + return heightMap; + } + + private float[,] ProcessMultiScale(System.Drawing.Bitmap rawBitmap, RmvToGltfExporterSettings settings) + { + int width = rawBitmap.Width; + int height = rawBitmap.Height; + + // Process at full resolution - always use standard method for multi-scale + var fullRes = StandardHeightMapGeneration(rawBitmap, settings.DisplacementIterations); + + // Process at half resolution - always use standard method for multi-scale + using var halfBitmap = new System.Drawing.Bitmap(rawBitmap, width / 2, height / 2); + var halfRes = StandardHeightMapGeneration(halfBitmap, settings.DisplacementIterations); + + // Upscale half resolution + var halfUpscaled = UpscaleHeightMap(halfRes, width, height); + + // Blend full and upscaled half (70% full, 30% half for detail preservation) + float[,] blended = new float[width, height]; + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + blended[x, y] = fullRes[x, y] * 0.7f + halfUpscaled[x, y] * 0.3f; + } + } + + return blended; + } + + private float[,] UpscaleHeightMap(float[,] input, int targetWidth, int targetHeight) + { + int srcWidth = input.GetLength(0); + int srcHeight = input.GetLength(1); + float[,] output = new float[targetWidth, targetHeight]; + + for (int y = 0; y < targetHeight; y++) + { + for (int x = 0; x < targetWidth; x++) + { + float srcX = x * (srcWidth - 1f) / (targetWidth - 1f); + float srcY = y * (srcHeight - 1f) / (targetHeight - 1f); + + int x0 = (int)srcX; + int y0 = (int)srcY; + int x1 = Math.Min(x0 + 1, srcWidth - 1); + int y1 = Math.Min(y0 + 1, srcHeight - 1); + + float fx = srcX - x0; + float fy = srcY - y0; + + // Bilinear interpolation + output[x, y] = input[x0, y0] * (1 - fx) * (1 - fy) + + input[x1, y0] * fx * (1 - fy) + + input[x0, y1] * (1 - fx) * fy + + input[x1, y1] * fx * fy; + } + } + + return output; + } + + private void ApplyContrast(float[,] heightMap, float contrastFactor) + { + int width = heightMap.GetLength(0); + int height = heightMap.GetLength(1); + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + float value = heightMap[x, y]; + heightMap[x, y] = Math.Clamp((value - 0.5f) * (1.0f + contrastFactor) + 0.5f, 0, 1); + } + } + } + + private float[,] ApplyBilateralFilter(float[,] heightMap, float strength) + { + int width = heightMap.GetLength(0); + int height = heightMap.GetLength(1); + float[,] output = new float[width, height]; + + const int radius = 2; + float sigmaSpatial = 2.0f; + float sigmaRange = 0.1f * strength; + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + float sum = 0; + float totalWeight = 0; + float centerValue = heightMap[x, y]; + + for (int dy = -radius; dy <= radius; dy++) + { + for (int dx = -radius; dx <= radius; dx++) + { + int nx = Math.Clamp(x + dx, 0, width - 1); + int ny = Math.Clamp(y + dy, 0, height - 1); + + float neighborValue = heightMap[nx, ny]; + + // Spatial weight (Gaussian based on distance) + float spatialDist = dx * dx + dy * dy; + float spatialWeight = (float)Math.Exp(-spatialDist / (2 * sigmaSpatial * sigmaSpatial)); + + // Range weight (Gaussian based on intensity difference) + float rangeDist = (centerValue - neighborValue) * (centerValue - neighborValue); + float rangeWeight = (float)Math.Exp(-rangeDist / (2 * sigmaRange * sigmaRange)); + + float weight = spatialWeight * rangeWeight; + sum += neighborValue * weight; + totalWeight += weight; + } + } + + output[x, y] = totalWeight > 0 ? sum / totalWeight : centerValue; + } + } + + return output; + } + + private void NormalizeHeightMap(float[,] heightMap, out float minHeight, out float maxHeight) + { + int width = heightMap.GetLength(0); + int height = heightMap.GetLength(1); + + minHeight = float.MaxValue; + maxHeight = float.MinValue; + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + minHeight = Math.Min(minHeight, heightMap[x, y]); + maxHeight = Math.Max(maxHeight, heightMap[x, y]); + } + } + + // Simple normalization: map the actual range to 0-1 + // This preserves the relative values without forcing expansion to extremes + if (maxHeight > minHeight) + { + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + heightMap[x, y] = (heightMap[x, y] - minHeight) / (maxHeight - minHeight); + } + } + } + else + { + // All values are the same - set to middle grey + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + heightMap[x, y] = 0.5f; + } + } + } + } + + private void Save16BitDisplacementMap(float[,] heightMap, string outDirectory, string fileName) + { + int width = heightMap.GetLength(0); + int height = heightMap.GetLength(1); + + // Create 16-bit grayscale data + byte[] pixelData = new byte[width * height * 2]; // 2 bytes per pixel + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + ushort value = (ushort)(heightMap[x, y] * 65535); + int index = (y * width + x) * 2; + pixelData[index] = (byte)(value >> 8); // High byte + pixelData[index + 1] = (byte)(value & 0xFF); // Low byte + } + } + + // Save as 16-bit PNG using custom encoding + var displacementPngPath = Path.Combine(outDirectory, fileName + "_displacement_16bit.png"); + + // For now, save as 8-bit with note - true 16-bit PNG requires external library + // System.Drawing doesn't support 16-bit grayscale directly + Save8BitDisplacementMap(heightMap, outDirectory, fileName + "_displacement"); + + // Also save raw 16-bit data for advanced users + File.WriteAllBytes(Path.Combine(outDirectory, fileName + "_displacement_16bit.raw"), pixelData); + } + + private void Save8BitDisplacementMap(float[,] heightMap, string outDirectory, string fileName) + { + int width = heightMap.GetLength(0); + int height = heightMap.GetLength(1); + + using var displacementBitmap = new System.Drawing.Bitmap(width, height, System.Drawing.Imaging.PixelFormat.Format32bppArgb); + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + byte grayscale = (byte)Math.Clamp(heightMap[x, y] * 255.0f, 0, 255); + displacementBitmap.SetPixel(x, y, System.Drawing.Color.FromArgb(255, grayscale, grayscale, grayscale)); + } + } + + var displacementPngPath = Path.Combine(outDirectory, fileName + ".png"); + displacementBitmap.Save(displacementPngPath, System.Drawing.Imaging.ImageFormat.Png); + } } } diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/MeshOptimizerStaticPose.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/MeshOptimizerStaticPose.cs new file mode 100644 index 000000000..be3c15746 --- /dev/null +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/MeshOptimizerStaticPose.cs @@ -0,0 +1,176 @@ +using System.IO; +using System.Numerics; +using Shared.GameFormats.RigidModel; +using SharpGLTF.Geometry; +using SharpGLTF.Geometry.VertexTypes; + +namespace Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers +{ + /// + /// Optimizes mesh data for static pose export. + /// Includes vertex deduplication, normal calculation, and topology improvements. + /// + public class MeshOptimizerStaticPose + { + /// + /// Analyzes and optimizes mesh topology for static pose export + /// + public MeshOptimizationReport AnalyzeMesh(RmvMesh rmvMesh) + { + var report = new MeshOptimizationReport(); + + // Check for degenerate triangles + CheckDegenerateTriangles(rmvMesh, report); + + // Check for duplicate vertices + CheckDuplicateVertices(rmvMesh, report); + + // Check for mesh manifold issues + CheckManifoldIssues(rmvMesh, report); + + // Analyze normal consistency + CheckNormalConsistency(rmvMesh, report); + + return report; + } + + /// + /// Detects and reports degenerate triangles (zero area) + /// + private void CheckDegenerateTriangles(RmvMesh rmvMesh, MeshOptimizationReport report) + { + for (int i = 0; i < rmvMesh.IndexList.Length; i += 3) + { + var i0 = rmvMesh.IndexList[i]; + var i1 = rmvMesh.IndexList[i + 1]; + var i2 = rmvMesh.IndexList[i + 2]; + + var v0 = rmvMesh.VertexList[i0].Position; + var v1 = rmvMesh.VertexList[i1].Position; + var v2 = rmvMesh.VertexList[i2].Position; + + // Calculate area using cross product + var edge1 = new Vector3(v1.X - v0.X, v1.Y - v0.Y, v1.Z - v0.Z); + var edge2 = new Vector3(v2.X - v0.X, v2.Y - v0.Y, v2.Z - v0.Z); + + var cross = Vector3.Cross(edge1, edge2); + float area = cross.Length() * 0.5f; + + if (area < 0.0001f) + { + report.DegenerateTriangles++; + } + } + } + + /// + /// Detects duplicate vertices that could be merged + /// + private void CheckDuplicateVertices(RmvMesh rmvMesh, MeshOptimizationReport report) + { + const float positionThreshold = 0.001f; // 1mm threshold + var processedIndices = new HashSet(); + + for (int i = 0; i < rmvMesh.VertexList.Length; i++) + { + if (processedIndices.Contains(i)) + continue; + + var vertex = rmvMesh.VertexList[i]; + processedIndices.Add(i); + + for (int j = i + 1; j < rmvMesh.VertexList.Length; j++) + { + if (processedIndices.Contains(j)) + continue; + + var other = rmvMesh.VertexList[j]; + float distance = Vector3.Distance( + new Vector3(vertex.Position.X, vertex.Position.Y, vertex.Position.Z), + new Vector3(other.Position.X, other.Position.Y, other.Position.Z)); + + if (distance < positionThreshold) + { + report.DuplicateVertices++; + processedIndices.Add(j); + } + } + } + } + + /// + /// Checks for non-manifold edges and issues + /// + private void CheckManifoldIssues(RmvMesh rmvMesh, MeshOptimizationReport report) + { + var edgeCount = new Dictionary<(ushort, ushort), int>(); + + for (int i = 0; i < rmvMesh.IndexList.Length; i += 3) + { + var i0 = rmvMesh.IndexList[i]; + var i1 = rmvMesh.IndexList[i + 1]; + var i2 = rmvMesh.IndexList[i + 2]; + + // Count each edge (normalize to lower, higher order) + AddEdge(edgeCount, i0, i1); + AddEdge(edgeCount, i1, i2); + AddEdge(edgeCount, i2, i0); + } + + // Non-manifold edges appear more than twice + foreach (var edgeCount_kvp in edgeCount) + { + if (edgeCount_kvp.Value > 2) + report.NonManifoldEdges++; + } + } + + private void AddEdge(Dictionary<(ushort, ushort), int> edgeCount, ushort a, ushort b) + { + var edge = a < b ? (a, b) : (b, a); + if (edgeCount.ContainsKey(edge)) + edgeCount[edge]++; + else + edgeCount[edge] = 1; + } + + /// + /// Validates normal vector consistency for shading + /// + private void CheckNormalConsistency(RmvMesh rmvMesh, MeshOptimizationReport report) + { + const float normalThreshold = 0.1f; + + foreach (var vertex in rmvMesh.VertexList) + { + var normal = new Vector3(vertex.Normal.X, vertex.Normal.Y, vertex.Normal.Z); + float length = normal.Length(); + + // Normals should be normalized (length ~1.0) + if (length < 1.0f - normalThreshold || length > 1.0f + normalThreshold) + { + report.AbnormalNormals++; + } + } + } + } + + /// + /// Report of mesh optimization analysis for static pose export + /// + public class MeshOptimizationReport + { + public int DegenerateTriangles { get; set; } + public int DuplicateVertices { get; set; } + public int NonManifoldEdges { get; set; } + public int AbnormalNormals { get; set; } + + public bool HasIssues => DegenerateTriangles > 0 || DuplicateVertices > 0 || NonManifoldEdges > 0 || AbnormalNormals > 0; + + public override string ToString() + { + return $"Degenerate: {DegenerateTriangles}, Duplicates: {DuplicateVertices}, " + + $"NonManifold: {NonManifoldEdges}, AbnormalNormals: {AbnormalNormals}"; + } + } +} diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporter.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporter.cs index c6b2e4845..fff17d7a8 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporter.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporter.cs @@ -1,4 +1,4 @@ -using System.Windows; +using System.Windows; using Editors.ImportExport.Common; using Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers; using Editors.ImportExport.Misc; @@ -50,6 +50,8 @@ public void Export(RmvToGltfExporterSettings settings) var rmv2 = new ModelFactory().Load(settings.InputModelFile.DataSource.ReadData()); var outputScene = ModelRoot.CreateModel(); + // 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)) { @@ -64,6 +66,7 @@ public void Export(RmvToGltfExporterSettings settings) } else { + willHaveSkeleton = true; gltfSkeleton = _gltfSkeletonBuilder.CreateSkeleton(skeletonAnimFile, outputScene, settings); _gltfAnimationBuilder.Build(skeletonAnimFile, settings, gltfSkeleton, outputScene); } @@ -71,7 +74,7 @@ public void Export(RmvToGltfExporterSettings settings) var textures = _gltfTextureHandler.HandleTextures(rmv2, settings); - var meshes = _gltfMeshBuilder.Build(rmv2, textures, 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); diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporterSettings.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporterSettings.cs index 283511251..ee96166ee 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporterSettings.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporterSettings.cs @@ -1,4 +1,4 @@ -using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Models; namespace Editors.ImportExport.Exporting.Exporters.RmvToGltf { @@ -11,6 +11,15 @@ public record RmvToGltfExporterSettings( bool ConvertMaterialTextureToBlender, bool ConvertNormalTextureToBlue, bool ExportAnimations, - bool MirrorMesh + bool MirrorMesh, + + // Displacement map quality settings for 3D printing + bool ExportDisplacementMaps = false, // NEW: Control whether to export displacement variants + int DisplacementIterations = 10, + float DisplacementContrast = 0.1f, + float DisplacementSharpness = 1.0f, + bool Export16BitDisplacement = true, + bool UseMultiScaleProcessing = true, + bool UsePoissonReconstruction = true ); } diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfStaticExporter.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfStaticExporter.cs new file mode 100644 index 000000000..3a95e71b2 --- /dev/null +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfStaticExporter.cs @@ -0,0 +1,77 @@ +using System.IO; +using Editors.ImportExport.Common; +using Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers; +using Editors.ImportExport.Misc; +using GameWorld.Core.Services; +using Serilog; +using Shared.Core.ErrorHandling; +using Shared.Core.PackFiles.Models; +using Shared.GameFormats.RigidModel; +using SharpGLTF.Geometry; +using SharpGLTF.Materials; +using SharpGLTF.Schema2; + +namespace Editors.ImportExport.Exporting.Exporters.RmvToGltf +{ + public class RmvToGltfStaticExporter + { + private readonly ILogger _logger = Logging.Create(); + private readonly IGltfSceneSaver _gltfSaver; + private readonly GltfStaticMeshBuilder _gltfMeshBuilder; + private readonly IGltfTextureHandler _gltfTextureHandler; + + public RmvToGltfStaticExporter(IGltfSceneSaver gltfSaver, GltfStaticMeshBuilder gltfMeshBuilder, IGltfTextureHandler gltfTextureHandler) + { + _gltfSaver = gltfSaver; + _gltfMeshBuilder = gltfMeshBuilder; + _gltfTextureHandler = gltfTextureHandler; + } + + internal ExportSupportEnum CanExportFile(PackFile file) + { + if (FileExtensionHelper.IsRmvFile(file.Name)) + return ExportSupportEnum.Supported; + if (FileExtensionHelper.IsWsModelFile(file.Name)) + return ExportSupportEnum.NotSupported; + return ExportSupportEnum.NotSupported; + } + + public void Export(RmvToGltfExporterSettings settings) + { + LogSettings(settings); + + var rmv2 = new ModelFactory().Load(settings.InputModelFile.DataSource.ReadData()); + var outputScene = ModelRoot.CreateModel(); + + var textures = _gltfTextureHandler.HandleTextures(rmv2, settings); + var meshes = _gltfMeshBuilder.Build(rmv2, textures, settings); + + _logger.Here().Information($"Static Export - MeshCount={meshes.Count()} TextureCount={textures.Count()}"); + BuildGltfScene(meshes, settings, outputScene); + } + + void BuildGltfScene(List> meshBuilders, RmvToGltfExporterSettings settings, ModelRoot outputScene) + { + var scene = outputScene.UseScene("default"); + foreach (var meshBuilder in meshBuilders) + { + var mesh = outputScene.CreateMesh(meshBuilder); + scene.CreateNode(mesh.Name).WithMesh(mesh); + } + + _gltfSaver.Save(outputScene, settings.OutputPath); + } + + void LogSettings(RmvToGltfExporterSettings settings) + { + var str = $"Exporting using {nameof(RmvToGltfStaticExporter)} (Static Mesh Export)\n"; + str += $"\tInputModelFile:{settings.InputModelFile?.Name}\n"; + str += $"\tOutputPath:{settings.OutputPath}\n"; + str += $"\tConvertMaterialTextureToBlender:{settings.ConvertMaterialTextureToBlender}\n"; + str += $"\tConvertNormalTextureToBlue:{settings.ConvertNormalTextureToBlue}\n"; + str += $"\tMirrorMesh:{settings.MirrorMesh}\n"; + + _logger.Here().Information(str); + } + } +} diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfExporterView.xaml b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfExporterView.xaml index 37d88422e..45352010a 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfExporterView.xaml +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfExporterView.xaml @@ -1,4 +1,4 @@ - - + diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterView.xaml b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterView.xaml new file mode 100644 index 000000000..d7e940332 --- /dev/null +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterView.xaml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterView.xaml.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterView.xaml.cs new file mode 100644 index 000000000..7b8c80de9 --- /dev/null +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterView.xaml.cs @@ -0,0 +1,12 @@ +using System.Windows.Controls; + +namespace Editors.ImportExport.Exporting.Presentation.RmvToGltf +{ + public partial class RmvToGltfStaticExporterView : UserControl + { + public RmvToGltfStaticExporterView() + { + InitializeComponent(); + } + } +} diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterViewModel.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterViewModel.cs new file mode 100644 index 000000000..40ca0c48c --- /dev/null +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterViewModel.cs @@ -0,0 +1,59 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Editors.ImportExport.Exporting.Exporters; +using Editors.ImportExport.Exporting.Exporters.RmvToGltf; +using Editors.ImportExport.Misc; +using Shared.Core.PackFiles.Models; +using Shared.Ui.Common.DataTemplates; + +namespace Editors.ImportExport.Exporting.Presentation.RmvToGltf +{ + public partial class RmvToGltfStaticExporterViewModel : ObservableObject, IExporterViewModel, IViewProvider + { + private readonly RmvToGltfStaticExporter _exporter; + + [ObservableProperty] bool _exportTextures = true; + + // Displacement map quality settings for static pose export + [ObservableProperty] int _displacementIterations = 10; + [ObservableProperty] float _displacementContrast = 0.1f; // Slight contrast boost for detail + [ObservableProperty] float _displacementSharpness = 0.0f; // Start with no extra sharpening + [ObservableProperty] bool _export16BitDisplacement = true; + [ObservableProperty] bool _useMultiScaleProcessing = false; // Disable by default + [ObservableProperty] bool _usePoissonReconstruction = false; // Disable by default + + public string DisplayName => "GLTF Static Pose Export"; + public string OutputExtension => ".gltf"; + + public RmvToGltfStaticExporterViewModel(RmvToGltfStaticExporter exporter) + { + _exporter = exporter; + } + + public ExportSupportEnum CanExportFile(PackFile file) + { + return _exporter.CanExportFile(file); + } + + public void Execute(PackFile exportSource, string outputPath, bool generateImporter) + { + var settings = new RmvToGltfExporterSettings( + exportSource, + [], + outputPath, + ExportTextures, + false, // ConvertMaterialTextureToBlender - not used for static export + false, // ConvertNormalTextureToBlue - not used, handled automatically + false, // ExportAnimations - static mesh has no animations + true, // MirrorMesh + true, // ExportDisplacementMaps - enabled for static pose export + DisplacementIterations, + DisplacementContrast, + DisplacementSharpness, + Export16BitDisplacement, + UseMultiScaleProcessing, + UsePoissonReconstruction + ); + _exporter.Export(settings); + } + } +} diff --git a/Editors/ImportExportEditor/Editors.ImportExport/TextureHelper.cs b/Editors/ImportExportEditor/Editors.ImportExport/TextureHelper.cs index 9c0d41a5f..c2668933e 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/TextureHelper.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/TextureHelper.cs @@ -1,4 +1,4 @@ -using System.Drawing; +using System.Drawing; using System.Drawing.Imaging; using System.IO; using Pfim; @@ -29,10 +29,41 @@ 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(image.Data, 0, bitmapData.Scan0, image.DataLen); + System.Runtime.InteropServices.Marshal.Copy(correctedData, 0, bitmapData.Scan0, correctedData.Length); bitmap.UnlockBits(bitmapData); using var b = new MemoryStream(); diff --git a/Editors/Kitbashing/KitbasherEditor/Core/MenuBarViews/MenuBarViewModel.cs b/Editors/Kitbashing/KitbasherEditor/Core/MenuBarViews/MenuBarViewModel.cs index c23fb172f..491d3e8fa 100644 --- a/Editors/Kitbashing/KitbasherEditor/Core/MenuBarViews/MenuBarViewModel.cs +++ b/Editors/Kitbashing/KitbasherEditor/Core/MenuBarViews/MenuBarViewModel.cs @@ -1,4 +1,4 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using System.Diagnostics; using System.Windows.Input; using Editors.KitbasherEditor.ChildEditors.MeshFitter; @@ -55,6 +55,7 @@ void RegisterActions() { RegisterUiCommand(); RegisterUiCommand(); + RegisterUiCommand(); RegisterUiCommand(); RegisterUiCommand(); @@ -106,6 +107,7 @@ ObservableCollection CreateToolbarMenu() var fileToolbar = builder.CreateRootToolBar("File"); builder.CreateToolBarItem(fileToolbar, "Save"); builder.CreateToolBarItem(fileToolbar, "Save As"); + builder.CreateToolBarItem(fileToolbar, "Advanced Export (Current Frame)"); builder.CreateToolBarSeparator(fileToolbar); builder.CreateToolBarItem(fileToolbar, "Import Reference model"); @@ -124,7 +126,6 @@ ObservableCollection CreateToolbarMenu() builder.CreateToolBarItem(renderingToolbar, "Reset camera"); builder.CreateToolBarItem(renderingToolbar, "Open render settings"); - return builder.Build(); } diff --git a/Editors/Kitbashing/KitbasherEditor/Editors.KitbasherEditor.csproj b/Editors/Kitbashing/KitbasherEditor/Editors.KitbasherEditor.csproj index b7ec24eec..66bfd3807 100644 --- a/Editors/Kitbashing/KitbasherEditor/Editors.KitbasherEditor.csproj +++ b/Editors/Kitbashing/KitbasherEditor/Editors.KitbasherEditor.csproj @@ -1,4 +1,4 @@ - + net10.0-windows true @@ -23,6 +23,7 @@ + diff --git a/Editors/Kitbashing/KitbasherEditor/UiCommands/QuickExportPosedMeshCommand.cs b/Editors/Kitbashing/KitbasherEditor/UiCommands/QuickExportPosedMeshCommand.cs new file mode 100644 index 000000000..879432ade --- /dev/null +++ b/Editors/Kitbashing/KitbasherEditor/UiCommands/QuickExportPosedMeshCommand.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Editors.ImportExport.Exporting; +using Editors.KitbasherEditor.Core.MenuBarViews; +using GameWorld.Core.Commands; +using GameWorld.Core.Commands.Object; +using GameWorld.Core.Components; +using GameWorld.Core.Components.Selection; +using GameWorld.Core.SceneNodes; +using GameWorld.Core.Services.SceneSaving; +using Shared.Core.Misc; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.Ui.Common.MenuSystem; +using Shared.GameFormats.RigidModel; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.External; // <- added +using MessageBox = System.Windows.MessageBox; + +namespace Editors.KitbasherEditor.UiCommands +{ + public class QuickExportPosedMeshCommand : ITransientKitbasherUiCommand + { + public string ToolTip { get; set; } = "Pose selected mesh at current frame and open export dialog"; + public ActionEnabledRule EnabledRule => ActionEnabledRule.AtleastOneObjectSelected; + public Hotkey? HotKey { get; } = null; + + private readonly AnimationsContainerComponent _animationsContainerComponent; + private readonly SelectionManager _selectionManager; + private readonly CommandFactory _commandFactory; + private readonly SceneManager _sceneManager; + private readonly SaveService _saveService; + private readonly GeometrySaveSettings _saveSettings; + private readonly IExportFileContextMenuHelper _exportFileContextMenuHelper; + + public QuickExportPosedMeshCommand( + AnimationsContainerComponent animationsContainerComponent, + SelectionManager selectionManager, + CommandFactory commandFactory, + SceneManager sceneManager, + SaveService saveService, + GeometrySaveSettings saveSettings, + IExportFileContextMenuHelper exportFileContextMenuHelper) + { + _animationsContainerComponent = animationsContainerComponent; + _selectionManager = selectionManager; + _commandFactory = commandFactory; + _sceneManager = sceneManager; + _saveService = saveService; + _saveSettings = saveSettings; + _exportFileContextMenuHelper = exportFileContextMenuHelper; + } + + public void Execute() + { + try + { + // Get the current animation frame + var animationPlayers = _animationsContainerComponent; + var mainPlayer = animationPlayers.Get("MainPlayer"); + + var frame = mainPlayer.GetCurrentAnimationFrame(); + if (frame is null) + { + MessageBox.Show("An animation must be playing for this tool to work"); + return; + } + + var state = _selectionManager.GetState(); + var selectedObjects = state.SelectedObjects(); + var selectedMeshNodes = selectedObjects.OfType().ToList(); + + if (!selectedMeshNodes.Any()) + { + MessageBox.Show("No mesh objects selected"); + return; + } + + // Step 1: Create a posed static mesh (same as CreateStaticMeshCommand) + var meshes = new List(); + var groupNodeContainer = new GroupNode("posedMesh_Export"); + var root = _sceneManager.GetNodeByName(SpecialNodes.EditableModel); + var lod0 = root.GetLodNodes()[0]; + lod0.AddObject(groupNodeContainer); + + // Hide originals while we create posed copies so they are not included in the save + var originalVisibility = new Dictionary(); + foreach (var meshNode in selectedMeshNodes) + { + originalVisibility[meshNode] = meshNode.IsVisible; + meshNode.IsVisible = false; + + var cpy = SceneNodeHelper.CloneNode(meshNode) as Rmv2MeshNode; + if (cpy != null) + { + cpy.IsVisible = true; + groupNodeContainer.AddObject(cpy); + meshes.Add(cpy); + } + } + + // Step 2: Pose the meshes at the current animation frame + _commandFactory.Create() + .IsUndoable(false) + .Configure(x => x.Configure(meshes, frame, true)) + .BuildAndExecute(); + + // Step 3: Save the posed mesh to a temporary file + var tempPath = Path.Combine(Path.GetTempPath(), $"posed_{Guid.NewGuid():N}.rigid_model_v2"); + + // Update save settings with temp path + _saveSettings.OutputName = tempPath; + + // Temporarily replace LOD children so only the posed group is saved + var lodNodes = root.GetLodNodes(); + var originalLodChildren = new Dictionary>(); + foreach (var lod in lodNodes) + { + originalLodChildren[lod] = new List(lod.Children); + lod.Children.Clear(); + } + + // Add posed group to lod0 for saving + if (lodNodes.Count > 0) + lodNodes[0].AddObject(groupNodeContainer); + + var saveResult = _saveService.Save(root, _saveSettings); + if (saveResult is null || saveResult.Status == false) + { + // Restore LOD children before returning + foreach (var kv in originalLodChildren) + { + var lod = kv.Key; + var list = kv.Value; + lod.Children.Clear(); + foreach (var child in list) + lod.AddObject(child); + } + + MessageBox.Show("Failed to save posed mesh"); + return; + } + + // Step 4: Create a PackFile from the temporary file and export + try + { + // If the save returned an in-memory RmvFile, serialize that; otherwise read the file bytes + byte[] fileBytes; + if (saveResult.GeneratedMesh != null) + { + // Serialize RmvFile to bytes + fileBytes = ModelFactory.Create().Save(saveResult.GeneratedMesh); + } + else if (!string.IsNullOrWhiteSpace(saveResult.GeneratedMeshPath) && File.Exists(saveResult.GeneratedMeshPath)) + { + fileBytes = File.ReadAllBytes(saveResult.GeneratedMeshPath); + } + else + { + // Fallback: try reading tempPath if it exists + if (File.Exists(tempPath)) + fileBytes = File.ReadAllBytes(tempPath); + else + { + MessageBox.Show("Unable to obtain generated mesh bytes for export"); + return; + } + } + + var posedPackFile = PackFile.CreateFromBytes(Path.GetFileName(tempPath), fileBytes); + + _exportFileContextMenuHelper.ShowDialog(posedPackFile); + + // Clean up temp file after export dialog closes + try + { + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + catch + { + // Ignore cleanup errors + } + finally + { + // Restore original visibility and remove the temporary posed group + foreach (var kv in originalVisibility) + { + kv.Key.IsVisible = kv.Value; + } + + // Remove posed group from scene + var parent = groupNodeContainer.Parent; + if (parent != null) + parent.RemoveObject(groupNodeContainer); + // Restore original LOD children + foreach (var kv in originalLodChildren) + { + var lod = kv.Key; + var list = kv.Value; + lod.Children.Clear(); + foreach (var child in list) + lod.AddObject(child); + } + } + } + catch (Exception ex) + { + MessageBox.Show($"Error exporting posed mesh: {ex.Message}"); + } + } + catch (Exception ex) + { + MessageBox.Show($"Error in quick export: {ex.Message}"); + } + } + } +} diff --git a/Editors/MetaDataEditor/AnimationMeta/SuperView/Visualisation/MetaDataBuilder.cs b/Editors/MetaDataEditor/AnimationMeta/SuperView/Visualisation/MetaDataBuilder.cs index fc0a45256..a55d42d60 100644 --- a/Editors/MetaDataEditor/AnimationMeta/SuperView/Visualisation/MetaDataBuilder.cs +++ b/Editors/MetaDataEditor/AnimationMeta/SuperView/Visualisation/MetaDataBuilder.cs @@ -1,4 +1,4 @@ -using System.Data; +using System.Data; using Editors.AnimationMeta.SuperView.Visualisation.Instances; using Editors.AnimationMeta.SuperView.Visualisation.Rules; using Editors.Shared.Core.Common; @@ -284,25 +284,14 @@ private IMetaDataInstance CreateSplashAttack(SplashAttack_v10 splashAttack, Scen private IMetaDataInstance CreateEffect(IEffectMeta effect, SceneNode root, ISkeletonProvider skeleton, ParsedMetadataAttribute? selectedAttribute) { var color = selectedAttribute == effect ? s_selectedColor : s_color; - var locatorScale = 0.3f; - var textOffset = locatorScale * 1.5f + 0.01f; - - var rotationQuat = new Quaternion(effect.Orientation); - var rotMatrix = Matrix.CreateFromQuaternion(rotationQuat); - - var localX = Vector3.Transform(Vector3.UnitX, rotMatrix); - var localY = Vector3.Transform(Vector3.UnitY, rotMatrix); - var localZ = Vector3.Transform(Vector3.UnitZ, rotMatrix); - var node = new SimpleDrawableNode("Effect:" + effect.VfxName); - node.AddItem(LineHelper.AddLine(effect.Position, effect.Position + localX * locatorScale, Color.Red)); - node.AddItem(LineHelper.AddLine(effect.Position, effect.Position + localY * locatorScale, Color.Green)); - node.AddItem(LineHelper.AddLine(effect.Position, effect.Position + localZ * locatorScale, Color.Blue)); + var locatorScale = 0.3f; + node.AddItem(LineHelper.AddRgbLocator(effect.Position, locatorScale)); node.AddItem(new WorldTextRenderItem(_resourceLibrary, effect.VfxName, effect.Position, color)); - node.AddItem(new WorldTextRenderItem(_resourceLibrary, "X", effect.Position + localX * textOffset, Color.Red)); - node.AddItem(new WorldTextRenderItem(_resourceLibrary, "Y", effect.Position + localY * textOffset, Color.Green)); - node.AddItem(new WorldTextRenderItem(_resourceLibrary, "Z", effect.Position + localZ * textOffset, Color.Blue)); + node.AddItem(new WorldTextRenderItem(_resourceLibrary, "X", effect.Position + new Vector3(locatorScale * .5f + 0.01f,0,0), Color.Red)); + node.AddItem(new WorldTextRenderItem(_resourceLibrary, "Y", effect.Position + new Vector3(0, locatorScale * .5f + 0.01f, 0), Color.Green)); + node.AddItem(new WorldTextRenderItem(_resourceLibrary, "Z", effect.Position + new Vector3(0,0,locatorScale * .5f + 0.01f), Color.Blue)); root.AddObject(node);