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..ed2fad83f 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Editors.ImportExport.csproj +++ b/Editors/ImportExportEditor/Editors.ImportExport/Editors.ImportExport.csproj @@ -22,4 +22,10 @@ + + + + + + diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/DdsToNormalPng/DdsToNormalPngExporter.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/DdsToNormalPng/DdsToNormalPngExporter.cs index dafbf1cf2..601f013e7 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/DdsToNormalPng/DdsToNormalPngExporter.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/DdsToNormalPng/DdsToNormalPngExporter.cs @@ -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..d0d0c3367 --- /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..d80d16729 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfMeshBuilder.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfMeshBuilder.cs @@ -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..f2d0f1acf --- /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..a07658884 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/MeshOptimizer3DPrint.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/MeshOptimizer3DPrint.cs new file mode 100644 index 000000000..35b4243b1 --- /dev/null +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/MeshOptimizer3DPrint.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 3D printing quality. + /// Includes vertex deduplication, normal calculation, and topology improvements. + /// + public class MeshOptimizer3DPrint + { + /// + /// Analyzes and optimizes mesh topology for 3D printing + /// + 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 3D printing + /// + 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..f056f9e07 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporter.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporter.cs @@ -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..74f559cab 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporterSettings.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporterSettings.cs @@ -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..b12aa0a4f --- /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..8bde283c7 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfExporterView.xaml +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfExporterView.xaml @@ -43,5 +43,5 @@ - + 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..1bcc338ac --- /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..6e284bb9a --- /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..c9c461974 --- /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 3D printing + [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 for 3D Printing"; + 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 3D printing! + 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..e21253e78 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/TextureHelper.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/TextureHelper.cs @@ -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..706b7262c 100644 --- a/Editors/Kitbashing/KitbasherEditor/Core/MenuBarViews/MenuBarViewModel.cs +++ b/Editors/Kitbashing/KitbasherEditor/Core/MenuBarViews/MenuBarViewModel.cs @@ -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..6a24c8f28 100644 --- a/Editors/Kitbashing/KitbasherEditor/Editors.KitbasherEditor.csproj +++ b/Editors/Kitbashing/KitbasherEditor/Editors.KitbasherEditor.csproj @@ -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..e02198e59 --- /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/GltfMeshBuilder.cs b/GltfMeshBuilder.cs new file mode 100644 index 000000000..bf7137519 --- /dev/null +++ b/GltfMeshBuilder.cs @@ -0,0 +1,16 @@ +public List> Build(RmvFile rmv2, List textures, RmvToGltfExporterSettings settings, bool willUseSkeleton = false) +{ + var lodLevel = rmv2.ModelList.First(); + var hasSkeleton = string.IsNullOrWhiteSpace(rmv2.Header.SkeletonName) == false; + + 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 = GenerateMesh(rmvMesh.Mesh, rmvMesh.Material.ModelName, gltfMaterial, hasSkeleton && willUseSkeleton, settings.MirrorMesh); + meshes.Add(gltfMesh); + } + return meshes; +} diff --git a/RmvToGltfExporter.cs b/RmvToGltfExporter.cs new file mode 100644 index 000000000..58ca22c01 --- /dev/null +++ b/RmvToGltfExporter.cs @@ -0,0 +1 @@ +var meshes = _gltfMeshBuilder.Build(rmv2, textures, settings, gltfSkeleton != null);