diff --git a/Plugins/Nya/Mesh.cs b/Plugins/Nya/Mesh.cs
index c89de00..eed4100 100644
--- a/Plugins/Nya/Mesh.cs
+++ b/Plugins/Nya/Mesh.cs
@@ -78,7 +78,7 @@ private static (FaceFlags, Polygon) ConvertFace(
faceFlag.NoLight = face.NoLight | settings.ModelType == NyaArguments.ModelTypes.NoLight;
// Read polygon
- Polygon polygon = Mesh.ConvertPolygon(face, faceFlag, group, modelTextures, !settings.NoUV, ref vertices, ref uvTextures);
+ Polygon polygon = Mesh.ConvertPolygon(face, faceFlag, group, modelTextures, !settings.NoUV, settings.TextureMergeThreshold, ref vertices, ref uvTextures);
return (faceFlag, polygon);
}
@@ -91,6 +91,8 @@ private static (FaceFlags, Polygon) ConvertFace(
/// Model object group
/// Textures from model file textures
/// Unwrap model textures by UV
+ /// Texture similarity threshold percentage (0.0 to 100.0)
+ /// above which 2 textures will be considered identical
/// Model vertices
/// Embed model vertices
private static Polygon ConvertPolygon(
@@ -99,6 +101,7 @@ private static Polygon ConvertPolygon(
Group group,
List modelTextures,
bool unwrapTextures,
+ double textureMergeThreshold,
ref List vertices,
ref List uvTextures)
{
@@ -143,6 +146,10 @@ private static Polygon ConvertPolygon(
if (texture != null)
{
+ List finalUvs = new List(face.Uv);
+ List finalNormals = new List(face.Normals);
+ List finalVertices = new List(face.Vertices);
+
// Canonicalize quad UV ordering so GetUnwrap sees
// [TL, TR, BR, BL] every time. Mirrored faces arrive here
// with the same 4 UV points but traced in the opposite
@@ -152,63 +159,50 @@ private static Polygon ConvertPolygon(
// rotated 90° relative to the other side.
// Padded triangles keep uv[2]==uv[3] and must not be
// reordered by this pass.
- if (wasQuad)
- {
- // Find corner closest to UV top-left (minU, maxV in V-up space).
- double minU = face.Uv.Select(i => group.Uv[i].X).Min();
- double maxV = face.Uv.Select(i => group.Uv[i].Y).Max();
+ List rawUvs = face.Uv.Select(i => group.Uv[i]).ToList();
+ var canonicalizationResult = CanonicalizeFace(rawUvs, wasQuad);
- int topLeft = 0;
- double bestDistSq = double.MaxValue;
+ // Reorder everything according to the canonical indices
+ finalUvs.Clear();
+ finalNormals.Clear();
+ finalVertices.Clear();
- for (int i = 0; i < 4; i++)
- {
- Vector3D c = group.Uv[face.Uv[i]];
- double du = c.X - minU;
- double dv = maxV - c.Y;
- double d = (du * du) + (dv * dv);
-
- if (d < bestDistSq)
- {
- bestDistSq = d;
- topLeft = i;
- }
- }
-
- // Cyclic shift so topLeft lands at index 0.
- List rotatedUvs = new List(4);
- List rotatedNormals = new List(4);
- List rotatedVertices = new List(4);
+ for (int i = 0; i < 4; i++)
+ {
+ int originalIndex = canonicalizationResult.NewToOldIndices[i];
+ finalUvs.Add(face.Uv[originalIndex]);
+ finalNormals.Add(face.Normals[originalIndex]);
+ finalVertices.Add(face.Vertices[originalIndex]);
+ }
- for (int i = 0; i < 4; i++)
- {
- rotatedUvs.Add(face.Uv[(i + topLeft) % 4]);
- rotatedNormals.Add(face.Normals[(i + topLeft) % 4]);
- rotatedVertices.Add(face.Vertices[(i + topLeft) % 4]);
- }
+ // Generate texture
+ TextureResult result = Mesh.GetUvMappedTexture(texture, finalUvs, group.Uv, wasQuad, textureMergeThreshold, ref uvTextures);
+ faceFlag.TextureId = result.TextureId;
- // Check UV winding. For a CW quad [TL, TR, BR, BL] in
- // V-up UV space, (uv[1]-uv[0]) × (uv[3]-uv[0]) has
- // negative Z. Positive Z means CCW — swap indices 1↔3
- // to convert [TL, BL, BR, TR] → [TL, TR, BR, BL].
- Vector3D e01 = group.Uv[rotatedUvs[1]] - group.Uv[rotatedUvs[0]];
- Vector3D e03 = group.Uv[rotatedUvs[3]] - group.Uv[rotatedUvs[0]];
- double signedArea = (e01.X * e03.Y) - (e01.Y * e03.X);
+ // Reorder vertices depending on how the texture matched
+ if (result.VertexPermutation != null)
+ {
+ List newUvs = new List(4);
+ List newNormals = new List(4);
+ List newVertices = new List(4);
- if (signedArea > 0.0)
+ for (int i = 0; i < 4; i++)
{
- (rotatedUvs[1], rotatedUvs[3]) = (rotatedUvs[3], rotatedUvs[1]);
- (rotatedNormals[1], rotatedNormals[3]) = (rotatedNormals[3], rotatedNormals[1]);
- (rotatedVertices[1], rotatedVertices[3]) = (rotatedVertices[3], rotatedVertices[1]);
+ int srcIdx = result.VertexPermutation[i];
+ newUvs.Add(finalUvs[srcIdx]);
+ newNormals.Add(finalNormals[srcIdx]);
+ newVertices.Add(finalVertices[srcIdx]);
}
- face.Uv = rotatedUvs;
- face.Normals = rotatedNormals;
- face.Vertices = rotatedVertices;
+ finalUvs = newUvs;
+ finalNormals = newNormals;
+ finalVertices = newVertices;
}
- // Generate texture
- faceFlag.TextureId = Mesh.GetUvMappedTexture(texture, face.Uv, group.Uv, ref uvTextures);
+ // Reinjection
+ face.Uv = finalUvs;
+ face.Normals = finalNormals;
+ face.Vertices = finalVertices;
}
else
{
@@ -284,54 +278,316 @@ private static Polygon ConvertPolygon(
return polygon;
}
+ ///
+ /// Specifies transforms that can be applied to a texture.
+ ///
+ [Flags]
+ public enum UvTransform
+ {
+ ///
+ /// Original texture, no transform applied.
+ ///
+ None = 0,
+
+ ///
+ /// Texture is mirrored horizontally.
+ ///
+ HorizontalFlip = 1,
+
+ ///
+ /// Texture is mirrored vertically.
+ ///
+ VerticalFlip = 2,
+
+ ///
+ /// Texture is mirrored horizontally and vertically.
+ ///
+ Both = HorizontalFlip | VerticalFlip
+ }
+
+ ///
+ /// Represents the detailed result of a UV mapping operation.
+ ///
+ ///
+ /// This object is returned by .
+ ///
+ public class TextureResult
+ {
+ ///
+ /// Gets or sets the identifier of the texture (either already existing or newly created)
+ /// within the UV texture atlas.
+ ///
+ public int TextureId { get; set; }
+
+ ///
+ /// Gets or sets the vertex permutation array used to map vertices of a polygon to their canonical order.
+ ///
+ public int[]? VertexPermutation { get; set; } = null;
+ }
+
///
/// Get UV mapped texture from base texture
///
/// Base texture
/// UV coord indicies for quad
/// All UV coords
+ /// False if the polygon was a triangle before being converted to a quad.
+ /// True if the polygon always was a Quad
+ /// Texture similarity threshold percentage (0.0 to 100.0)
+ /// above which 2 textures will be considered identical
/// UV texture atlas
/// Number of already existing or new texture
- private static int GetUvMappedTexture(Texture baseTexture, List uv, List uvCoords, ref List uvTextures)
+ private static TextureResult GetUvMappedTexture(
+ Texture baseTexture,
+ List uv,
+ List uvCoords,
+ bool wasQuad,
+ double textureMergeThreshold,
+ ref List uvTextures)
+ {
+ List currentFaceUvs = uv.Select(coord => uvCoords[coord]).ToList();
+
+ // In order to detect which textures are the same we first check whether their shapes are similar (including mirror versions).
+ // Similar is defined as a % of their length/width
+ // (fall back to half a pixel tolerance to insure pixel perfect behavior in case of high merge threshold)
+ double minU = currentFaceUvs.Min(p => p.X);
+ double maxU = currentFaceUvs.Max(p => p.X);
+ double minV = currentFaceUvs.Min(p => p.Y);
+ double maxV = currentFaceUvs.Max(p => p.Y);
+ double faceWidth = maxU - minU;
+ double faceHeight = maxV - minV;
+ double toleranceFactor = (100.0 - textureMergeThreshold) / 100.0;
+ double uEpsilon = Math.Max(faceWidth * toleranceFactor, 0.5 / baseTexture.Width);
+ double vEpsilon = Math.Max(faceHeight * toleranceFactor, 0.5 / baseTexture.Height);
+
+ int bestTextureId = -1;
+ double bestSimilarityScore = -1.0;
+ int[]? bestPermutation = null;
+ for (int i = 0; i < uvTextures.Count; i++)
+ {
+ Texture existingTexture = uvTextures[i];
+
+ if (existingTexture.GetBaseName() != baseTexture.Name)
+ {
+ continue;
+ }
+
+ List existingUvs = existingTexture.UV.Select(id => uvCoords[id]).ToList();
+ if (Mesh.IsUvSameShape(existingUvs, currentFaceUvs, wasQuad, uEpsilon, vEpsilon,
+ out UvTransform detectedTransform,
+ out int[]? currentToCanonicalVertexOrder) && currentToCanonicalVertexOrder is not null)
+ {
+ //The shapes are similar, now we reorder to vertices so the content of the textures can be compared
+ List reorderedCurrentUvs = new List(4);
+
+ for (int j = 0; j < 4; j++)
+ {
+ reorderedCurrentUvs.Add(currentFaceUvs[currentToCanonicalVertexOrder[j]]);
+ }
+
+ Texture currentUnwrap = Texture.GetUnwrap(baseTexture, reorderedCurrentUvs);
+
+ //And we compare the content of the textures
+ double currentScore = currentUnwrap.CalculateSimilarityTo(existingTexture);
+
+ if (currentScore >= textureMergeThreshold && currentScore > bestSimilarityScore)
+ {
+ bestSimilarityScore = currentScore;
+ bestTextureId = i;
+ bestPermutation = currentToCanonicalVertexOrder;
+
+ if (bestSimilarityScore >= 100.0)
+ {
+ break;
+ }
+ }
+ }
+ }
+
+ if (bestTextureId >= 0)
+ {
+ return new TextureResult { TextureId = bestTextureId, VertexPermutation = bestPermutation };
+ }
+
+ // No match with existing texture, we extract a new one
+ Texture newUnwrap = Texture.GetUnwrap(baseTexture, currentFaceUvs);
+ newUnwrap.UV = uv.ToArray();
+
+ int newId = uvTextures.Count;
+ uvTextures.Add(newUnwrap);
+
+ return new TextureResult { TextureId = newId };
+ }
+
+ ///
+ /// Determines whether two sets of UV coordinates share the same geometric shape within a specified tolerance,
+ /// checking across multiple orientation configurations (default orientation, horizontal flip, vertical flip and both).
+ /// The reference list of UV coordinates to compare against.
+ /// The list of UV coordinates being evaluated for a potential match.
+ /// False if the polygon was a triangle before being converted to a quad.
+ /// True if the polygon always was a Quad
+ /// The maximum allowed absolute difference along the U (X) axis.
+ /// The maximum allowed absolute difference along the V (Y) axis.
+ /// When this method returns, contains the applied to achieve the match;
+ /// otherwise, UvTransform.None.
+ /// When this method returns, contains an array mapping the current vertices
+ /// to their canonical sequence if a match is found; otherwise, null.
+ /// true if matches the shape of under any tested transformation; otherwise, false.
+ private static bool IsUvSameShape(
+ List existingUvs,
+ List testedUvs,
+ bool wasQuad,
+ double uEpsilon,
+ double vEpsilon,
+ out UvTransform transform,
+ out int[]? currentToCanonicalVertexOrder)
{
- // Check if texture mapped to this region exists already (with a tolerance to a half pixel difference)
- double uEpsilon = 0.5 / baseTexture.Width;
- double vEpsilon = 0.5 / baseTexture.Height;
- var createdFromBase = uvTextures.Select((texture, index) => new KeyValuePair(index, texture)).Where(texture => texture.Value.GetBaseName() == baseTexture.Name).ToList();
- var existing = createdFromBase
- .Where(texture => texture.Value.UV.Select((id, i) =>
+ transform = UvTransform.None;
+ currentToCanonicalVertexOrder = null;
+
+ if (existingUvs.Count != testedUvs.Count)
+ {
+ return false;
+ }
+
+ Vector3D originExistingUvs = existingUvs[0];
+ List centeredExisting = existingUvs.Select(p =>
+ new Vector3D(p.X - originExistingUvs.X, p.Y - originExistingUvs.Y, p.Z)).ToList();
+
+ var configs = new[]
+ {
+ (Transform: UvTransform.None, MirrorFunc: (Func)(p => p)),
+ (Transform: UvTransform.HorizontalFlip, MirrorFunc: (p => new Vector3D(-p.X, p.Y, p.Z))),
+ (Transform: UvTransform.VerticalFlip, MirrorFunc: (p => new Vector3D(p.X, -p.Y, p.Z))),
+ (Transform: UvTransform.Both, MirrorFunc: (p => new Vector3D(-p.X, -p.Y, p.Z)))
+ };
+
+ foreach (var cfg in configs)
+ {
+ List mirroredTestedUvs = testedUvs.Select(cfg.MirrorFunc).ToList();
+
+ var canonicalizationResult = CanonicalizeFace(mirroredTestedUvs, wasQuad);
+ List canonicalizedTestedUvs = canonicalizationResult.OrderedCoords;
+
+ Vector3D originCanonicalizedTestedUvs = canonicalizedTestedUvs[0];
+ List centeredCanonicalizedTestedUvs = canonicalizedTestedUvs.Select(p =>
+ new Vector3D(p.X - originCanonicalizedTestedUvs.X, p.Y - originCanonicalizedTestedUvs.Y, p.Z)).ToList();
+
+ bool match = true;
+ for (int i = 0; i < centeredExisting.Count; i++)
{
- Vector3D currentUv = uvCoords[id];
- Vector3D targetUv = uvCoords[uv[i]];
- bool matchHorizontal = Math.Abs(currentUv.X - targetUv.X) < uEpsilon;
- bool matchVertical = Math.Abs(currentUv.Y - targetUv.Y) < vEpsilon;
-
- return matchHorizontal && matchVertical;
- }).All(val => val))
- .DefaultIfEmpty(new KeyValuePair(-1, baseTexture))
- .First().Key;
-
- // If not, generate new texture
- if (existing < 0)
+ if (Math.Abs(centeredExisting[i].X - centeredCanonicalizedTestedUvs[i].X) > uEpsilon ||
+ Math.Abs(centeredExisting[i].Y - centeredCanonicalizedTestedUvs[i].Y) > vEpsilon)
+ {
+ match = false;
+ break;
+ }
+ }
+
+ if (match)
+ {
+ transform = cfg.Transform;
+ currentToCanonicalVertexOrder = canonicalizationResult.NewToOldIndices;
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Contains the result of a polygon face canonicalization operation.
+ ///
+ /// The newly ordered and normalized list of 3D coordinates.
+ /// An array mapping each new position index back to its original index in the source list.
+ public record CanonicalizationResult(List OrderedCoords, int[] NewToOldIndices);
+
+ ///
+ /// Canonicalizes a polygon face by enforcing a consistent vertex order.
+ ///
+ /// The initial list of 3D vector coordinates representing the face vertices.
+ /// False if the polygon was a triangle before being converted to a quad.
+ /// True if the polygon always was a Quad
+ ///
+ /// A tuple containing:
+ ///
+ /// - orderedCoords: The newly ordered and normalized list of coordinates.
+ /// - originalIndices: An array mapping each new position back to its original index in .
+ ///
+ ///
+ private static CanonicalizationResult CanonicalizeFace(List rawCoords, bool wasQuad)
+ {
+ if (rawCoords.Count != 4)
{
- List coords = uv.Select(coord => uvCoords[coord]).ToList();
- Texture unwrap = Texture.GetUnwrap(baseTexture, coords);
- unwrap.UV = uv.ToArray();
- existing = uvTextures.Count;
+ int[] identity = Enumerable.Range(0, rawCoords.Count).ToArray();
+ return new CanonicalizationResult(new List(rawCoords), identity);
+ }
+
+ int vertexCount = wasQuad? 4 : 3;
+
+ // Find corner closest to UV top-left (minU, maxV in V-up space).
+ double minU = rawCoords.Min(p => p.X);
+ double maxV = rawCoords.Max(p => p.Y);
- var found = createdFromBase.FindIndex(pair => pair.Value.Hash == unwrap.Hash);
+ // Cyclic shift so topLeft lands at index 0.
+ int topLeft = 0;
+ double bestDistSq = double.MaxValue;
- if (found < 0)
+ for (int vertexID = 0; vertexID < vertexCount; vertexID++)
+ {
+ double du = rawCoords[vertexID].X - minU;
+ double dv = maxV - rawCoords[vertexID].Y;
+ double d = du * du + dv * dv;
+
+ if (d < bestDistSq)
{
- uvTextures.Add(unwrap);
+ bestDistSq = d;
+ topLeft = vertexID;
}
- else
+ }
+
+ int[] indices = new int[4];
+
+ for (int vertexID = 0; vertexID < vertexCount; vertexID++)
+ {
+ indices[vertexID] = (topLeft + vertexID) % vertexCount;
+ }
+
+ List ordered = new List(4);
+
+ for (int vertexID = 0; vertexID < vertexCount; vertexID++)
+ {
+ ordered.Add(rawCoords[indices[vertexID]]);
+ }
+
+ if(!wasQuad)
+ {
+ indices[3] = indices[2];
+ ordered.Add(ordered.Last());
+ }
+
+ // Check UV winding. For a CW quad [TL, TR, BR, BL] in
+ // V-up UV space, (uv[1]-uv[0]) × (uv[3]-uv[0]) has
+ // negative Z. Positive Z means CCW — swap indices 1↔3
+ // to convert [TL, BL, BR, TR] → [TL, TR, BR, BL].
+ Vector3D e01 = ordered[1] - ordered[0];
+ Vector3D e03 = ordered[3] - ordered[0];
+ double signedArea = (e01.X * e03.Y) - (e01.Y * e03.X);
+
+ if (signedArea > 0.0) // CCW → swap 1 et 3
+ {
+ (indices[1], indices[3]) = (indices[3], indices[1]);
+ (ordered[1], ordered[3]) = (ordered[3], ordered[1]);
+
+ //Update the duplicated last vertex when we are dealing with a triangle polygon
+ if(!wasQuad)
{
- return createdFromBase[found].Key;
+ indices[2] = indices[3];
+ ordered[2] = ordered[3];
}
}
- return existing;
+ return new CanonicalizationResult(ordered, indices);
}
///
diff --git a/Plugins/Nya/NyaArguments.cs b/Plugins/Nya/NyaArguments.cs
index fb3f89b..bba85c5 100644
--- a/Plugins/Nya/NyaArguments.cs
+++ b/Plugins/Nya/NyaArguments.cs
@@ -44,5 +44,14 @@ public enum ModelTypes
[CmdHelp("Makes exporter NOT generate new textures based on the UV map.")]
[CmdArgument("no-unwrap", "w")]
public bool NoUV { get; set; }
+
+ ///
+ /// Gets or sets the texture similarity threshold percentage (0.0-100.0) above which 2 textures will be considered identical
+ /// as to reuse one texture in place of the other and therefore save space in memory.
+ ///
+ [CmdHelp("Texture similarity threshold percentage (0.0 to 100.0) above which 2 textures will be considered identical as to reuse one in place of the other and therefore save space in memory.\nDefault value is 100 (pixel perfect match).")]
+ [CmdArgument("texture-merge-threshold", "m")]
+ public double TextureMergeThreshold { get; set; } = 100.0;
+
}
}
\ No newline at end of file
diff --git a/Plugins/Nya/Texture.cs b/Plugins/Nya/Texture.cs
index 2ad5bb4..3679098 100644
--- a/Plugins/Nya/Texture.cs
+++ b/Plugins/Nya/Texture.cs
@@ -111,30 +111,6 @@ public Texture(string name, SLIS.Image bitmap) : this(
[FieldOrder(0)]
public ushort Width { get; set; }
- ///
- /// Image hash
- ///
- private string hash = string.Empty;
-
- ///
- /// Gets image hash
- ///
- public string Hash
- {
- get
- {
- if (string.IsNullOrWhiteSpace(this.hash))
- {
- using (var sha1 = System.Security.Cryptography.SHA1.Create())
- {
- this.hash = string.Concat(sha1.ComputeHash(this.Data.SelectMany(pair => new byte[] { (byte)((pair >> 8) & 0xf), (byte)(pair & 0xf) }).ToArray()).Select(x => x.ToString("X2")));
- }
- }
-
- return this.hash;
- }
- }
-
///
/// Get UV unwrap texture
///
@@ -220,5 +196,237 @@ public string GetBaseName()
return this.Name;
}
+
+ ///
+ /// Compares this texture with another one.
+ /// The similarity score is based on color similarity and gradient (average detail/edge strength)
+ /// between the 2 images and between sub parts of both images.
+ ///
+ /// The texture to compare to
+ /// A similarity score between 0.0 (completely different) and 100.0 (exactly the same)
+ public double CalculateSimilarityTo(Texture other)
+ {
+ if (other == null)
+ {
+ return 0.0;
+ }
+
+ if (ReferenceEquals(this, other))
+ {
+ return 100.0;
+ }
+
+ return CalculateRecursiveSimilarity(this, other,
+ 0, 0, this.Width, this.Height,
+ 0, 0, other.Width, other.Height);
+ }
+
+ ///
+ /// Recursive similarity calculation using sliding windows.
+ /// The first image from which a region is being compared
+ /// The second image from which a region is being compared
+ /// x coordinate of the top left corner of the region of the first image being compared
+ /// y coordinate of the top left corner of the region of the first image being compared
+ /// Width of the region of the first image being compared
+ /// Height of the region of the first image being compared
+ /// x coordinate of the top left corner of the region of the second image being compared
+ /// y coordinate of the top left corner of the region of the second image being compared
+ /// Width of the region of the second image being compared
+ /// Height of the region of the second image being compared
+ /// A similarity score between 0.0 (completely different) and 100.0 (exactly the same)
+ ///
+ private static double CalculateRecursiveSimilarity(
+ Texture img1, Texture img2,
+ int x1, int y1, int w1, int h1, // région courante sur img1
+ int x2, int y2, int w2, int h2) // région courante sur img2
+ {
+ if (w1 * h1 < 4 || w2 * h2 < 4)
+ {
+ var avg1 = GetAverageColor(img1, x1, y1, w1, h1);
+ var avg2 = GetAverageColor(img2, x2, y2, w2, h2);
+ return ColorSimilarity(avg1, avg2);
+ }
+
+ // Mean color similarity
+ var avgFullImg1 = GetAverageColor(img1, x1, y1, w1, h1);
+ var avgFullImg2 = GetAverageColor(img2, x2, y2, w2, h2);
+ double colorSimilarity = ColorSimilarity(avgFullImg1, avgFullImg2);
+
+ // Structure similarity via mean gradient
+ double grad1 = GetMeanGradient(img1, x1, y1, w1, h1);
+ double grad2 = GetMeanGradient(img2, x2, y2, w2, h2);
+ double gradientSimilarity = GradientSimilarity(grad1, grad2);
+
+ // The deeper we go, the less relevant is structure similarity
+ double depthWeight = (w1 / img1.Width)/2;
+ double fullImgSimilarity = colorSimilarity * (1-depthWeight) + gradientSimilarity * (depthWeight);
+
+ int midW1 = (w1 + 1) / 2;
+ int midH1 = (h1 + 1) / 2;
+ int midW2 = (w2 + 1) / 2;
+ int midH2 = (h2 + 1) / 2;
+
+ // We do the same for each quadrant of the img
+ double tl = CalculateRecursiveSimilarity(img1, img2, x1, y1, midW1, midH1, x2, y2, midW2, midH2);
+ double tr = CalculateRecursiveSimilarity(img1, img2, x1 + midW1, y1, w1 - midW1, midH1, x2 + midW2, y2, w2 - midW2, midH2);
+ double bl = CalculateRecursiveSimilarity(img1, img2, x1, y1 + midH1, midW1, h1 - midH1, x2, y2 + midH2, midW2, h2 - midH2);
+ double br = CalculateRecursiveSimilarity(img1, img2, x1 + midW1, y1 + midH1, w1 - midW1, h1 - midH1, x2 + midW2, y2 + midH2, w2 - midW2, h2 - midH2);
+
+ double subImgSimilarity = (tl + tr + bl + br) / 4.0;
+
+ // Weights can be adjusted as long as their sum is equal to 1.0
+ return (fullImgSimilarity * 0.2 + subImgSimilarity * 0.8);
+ }
+
+ ///
+ /// Computes average RGB color of a rectangular region in the texture.
+ /// The texture in which a region is being processed
+ /// x coordinate of the top left corner of the region of the image
+ /// y coordinate of the top left corner of the region of the image
+ /// width of the region of the image
+ /// height of the region of the image
+ /// The average color of the given region of the texture
+ ///
+ private static (byte R, byte G, byte B) GetAverageColor(Texture texture, int startX, int startY, int width, int height)
+ {
+ long sumR = 0, sumG = 0, sumB = 0;
+ int count = 0;
+
+ for (int y = 0; y < height; y++)
+ {
+ for (int x = 0; x < width; x++)
+ {
+ int px = startX + x;
+ int py = startY + y;
+
+ if (px < texture.Width && py < texture.Height)
+ {
+ ushort pixel = texture.Data[py * texture.Width + px];
+ byte r = (byte)((pixel & 0x1F) << 3);
+ byte g = (byte)(((pixel >> 5) & 0x1F) << 3);
+ byte b = (byte)(((pixel >> 10) & 0x1F) << 3);
+
+ sumR += r;
+ sumG += g;
+ sumB += b;
+ count++;
+ }
+ }
+ }
+
+ if (count == 0)
+ {
+ return (0, 0, 0);
+ }
+
+ return (
+ (byte)(sumR / count),
+ (byte)(sumG / count),
+ (byte)(sumB / count)
+ );
+ }
+
+ ///
+ /// Similarity between two RGB colors (0.0 to 100.0).
+ /// Uses a simple RGB difference between the colors.
+ /// The first color being compared
+ /// The second color being compared
+ /// A similarity score between 0.0 and 100.0
+ ///
+ private static double ColorSimilarity((byte R, byte G, byte B) c1, (byte R, byte G, byte B) c2)
+ {
+ double simR = (255.0 - Math.Abs(c1.R - c2.R)) / 255.0;
+ double simG = (255.0 - Math.Abs(c1.G - c2.G)) / 255.0;
+ double simB = (255.0 - Math.Abs(c1.B - c2.B)) / 255.0;
+
+ double avgSim = (simR + simG + simB) / 3.0 * 100.0;
+ const double maxDelta = 8; //Difference above which we consider 0% similarity
+
+ return Math.Max(0, 100 * ((avgSim - (100 - maxDelta)) / maxDelta));
+ }
+
+ ///
+ /// Calculate a similarity score between 0.0 and 100.0 between two gradient values.
+ /// The first gradient being compared
+ /// The second gradient being compared
+ /// A similarity score between 0.0 (completely different) and 100.0 (identical)
+ ///
+ private static double GradientSimilarity(double g1, double g2)
+ {
+ if (g1 == 0 && g2 == 0)
+ {
+ return 100.0;
+ }
+
+ if (g1 == 0 || g2 == 0)
+ {
+ return 0.0;
+ }
+
+ double ratio = Math.Min(g1, g2) / Math.Max(g1, g2);
+
+ return ratio * 100.0;
+ }
+
+ ///
+ /// Calculates the mean gradient (average detail/edge strength) of a region.
+ /// Higher value = more details/texture variation.
+ /// The texture in which a region is being processed
+ /// x coordinate of the top left corner of the region of the image
+ /// y coordinate of the top left corner of the region of the image
+ /// width of the region of the image
+ /// height of the region of the image
+ /// The mean gradient of the given region of the texture
+ ///
+ private static double GetMeanGradient(Texture texture, int startX, int startY, int width, int height)
+ {
+ if (width < 2 || height < 2)
+ {
+ return 0.0;
+ }
+
+ long totalGradient = 0;
+ int count = 0;
+
+ for (int y = 0; y < height; y++)
+ {
+ for (int x = 0; x < width; x++)
+ {
+ int px = startX + x;
+ int py = startY + y;
+
+ if (px >= texture.Width - 1 || py >= texture.Height - 1)
+ {
+ continue;
+ }
+
+ ushort p1 = texture.Data[py * texture.Width + px]; // current pixel
+ ushort p2 = texture.Data[py * texture.Width + (px + 1)]; // right
+ ushort p3 = texture.Data[(py + 1) * texture.Width + px]; // below
+
+ byte r1 = (byte)((p1 & 0x1F) << 3);
+ byte g1 = (byte)(((p1 >> 5) & 0x1F) << 3);
+ byte b1 = (byte)(((p1 >> 10) & 0x1F) << 3);
+
+ byte r2 = (byte)((p2 & 0x1F) << 3);
+ byte g2 = (byte)(((p2 >> 5) & 0x1F) << 3);
+ byte b2 = (byte)(((p2 >> 10) & 0x1F) << 3);
+
+ byte r3 = (byte)((p3 & 0x1F) << 3);
+ byte g3 = (byte)(((p3 >> 5) & 0x1F) << 3);
+ byte b3 = (byte)(((p3 >> 10) & 0x1F) << 3);
+
+ // Simple luminance approximation
+ int lum1 = (r1 * 299 + g1 * 587 + b1 * 114) / 1000;
+ int lum2 = (r2 * 299 + g2 * 587 + b2 * 114) / 1000;
+ int lum3 = (r3 * 299 + g3 * 587 + b3 * 114) / 1000;
+
+ totalGradient += Math.Abs(lum1 - lum2) + Math.Abs(lum1 - lum3);
+ count++;
+ }
+ }
+
+ return count == 0 ? 0.0 : (double) totalGradient / count;
+ }
}
}
\ No newline at end of file
diff --git a/README.MD b/README.MD
index 551019c..fd46ede 100644
--- a/README.MD
+++ b/README.MD
@@ -88,6 +88,7 @@ Argument | Description
-------------|------------------------------------------------------
w, no-unwrap | Disable UV mapping preprocessor
t, type | Specify object type
``-t NoLight`` = Not shaded
``-t Flat`` = Flat shaded
``-t Smooth`` = Smooth shaded model
+m, texture-merge-threshold | Texture similarity threshold percentage (0.0 to 100.0) above which 2 textures will be considered identical as to reuse one in place of the other and therefore save space in memory. Defaults to pixel perfect threshold (100.0).
### Custom plugins
Custom plugins can be also written by referencing the ModelConverter.dll and implementing the interfaces within. Custom plugin dll with its dependencies can than be put inside ``/plugins/[plugin name]/`` folder.