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);