diff --git a/TombEditor/EditorActions.cs b/TombEditor/EditorActions.cs
index b3ea287e37..cd5156475e 100644
--- a/TombEditor/EditorActions.cs
+++ b/TombEditor/EditorActions.cs
@@ -1847,8 +1847,6 @@ public static bool ApplyTexture(Room room, VectorInt2 pos, SectorFace face, Text
if(!disableUndo)
_editor.UndoManager.PushGeometryChanged(_editor.SelectedRoom);
- texture.ParentArea = new Rectangle2();
-
bool textureApplied = ApplyTextureToFace(room, pos, face, texture);
if (textureApplied)
@@ -2279,8 +2277,6 @@ public static void TexturizeAll(Room room, SectorSelection selection, TextureAre
if (type == SectorFaceType.Ceiling) texture.Mirror();
RectangleInt2 area = selection.Valid ? selection.Area : _editor.SelectedRoom.LocalArea;
- texture.ParentArea = new Rectangle2();
-
for (int x = area.X0; x <= area.X1; x++)
for (int z = area.Y0; z <= area.Y1; z++)
{
diff --git a/TombLib/TombLib/LevelData/Compilers/AnimatedTextureLookupUtility.cs b/TombLib/TombLib/LevelData/Compilers/AnimatedTextureLookupUtility.cs
new file mode 100644
index 0000000000..2fc063c324
--- /dev/null
+++ b/TombLib/TombLib/LevelData/Compilers/AnimatedTextureLookupUtility.cs
@@ -0,0 +1,263 @@
+#nullable enable
+
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Numerics;
+using TombLib.Utils;
+
+namespace TombLib.LevelData.Compilers
+{
+ internal static class AnimatedTextureLookupUtility
+ {
+ private static float NormalizeLookupCoordinate(float value, float margin)
+ => (float)(Math.Round(value / margin) * margin);
+
+ ///
+ /// Quantizes a rectangle to the lookup margin so equivalent UV bounds collapse to a stable cache key.
+ ///
+ /// The rectangle to normalize.
+ /// The quantization step used by animated texture lookup comparisons.
+ /// A rectangle snapped to the lookup grid defined by .
+ public static Rectangle2 NormalizeLookupRectangle(Rectangle2 rect, float margin) => new(
+ NormalizeLookupCoordinate(rect.Start.X, margin),
+ NormalizeLookupCoordinate(rect.Start.Y, margin),
+ NormalizeLookupCoordinate(rect.End.X, margin),
+ NormalizeLookupCoordinate(rect.End.Y, margin)
+ );
+
+ ///
+ /// Determines whether two textures should be treated as the same logical texture for animated lookup purposes.
+ ///
+ /// The first texture to compare.
+ /// The second texture to compare.
+ /// when both textures resolve to the same identity; otherwise .
+ public static bool AreEquivalentTextures(Texture first, Texture second)
+ {
+ if (ReferenceEquals(first, second))
+ return true;
+
+ if (!string.IsNullOrEmpty(first.AbsolutePath) && !string.IsNullOrEmpty(second.AbsolutePath))
+ return first.AbsolutePath.Equals(second.AbsolutePath, StringComparison.OrdinalIgnoreCase);
+
+ if (first is TextureHashed firstHashed)
+ return second is TextureHashed secondHashed && firstHashed.Hash == secondHashed.Hash;
+
+ if (second is TextureHashed)
+ return false;
+
+ return first.Equals(second);
+ }
+
+ ///
+ /// Builds a stable hash for a texture identity using the same precedence as .
+ ///
+ /// The texture whose identity hash should be computed.
+ /// A hash code suitable for deduplication keys.
+ public static int GetTextureIdentityHash(Texture texture)
+ {
+ if (!string.IsNullOrEmpty(texture.AbsolutePath))
+ return StringComparer.OrdinalIgnoreCase.GetHashCode(texture.AbsolutePath);
+
+ if (texture is TextureHashed hashed)
+ return hashed.Hash.GetHashCode();
+
+ return texture.GetHashCode();
+ }
+
+ ///
+ /// Checks whether two rectangles match within the specified per-edge tolerance.
+ ///
+ /// The first rectangle.
+ /// The second rectangle.
+ /// The allowed epsilon for each rectangle edge.
+ /// when all corresponding edges are within .
+ public static bool RectanglesMatch(Rectangle2 first, Rectangle2 second, float margin)
+ => MathC.WithinEpsilon(first.X0, second.X0, margin) &&
+ MathC.WithinEpsilon(first.Y0, second.Y0, margin) &&
+ MathC.WithinEpsilon(first.X1, second.X1, margin) &&
+ MathC.WithinEpsilon(first.Y1, second.Y1, margin);
+
+ private static float GetRectangleMatchScore(Rectangle2 first, Rectangle2 second)
+ => Math.Abs(first.X0 - second.X0) +
+ Math.Abs(first.Y0 - second.Y0) +
+ Math.Abs(first.X1 - second.X1) +
+ Math.Abs(first.Y1 - second.Y1);
+
+ ///
+ /// Finds the closest frame in an animated set whose texture identity and bounds match the requested parent area.
+ ///
+ /// The animated texture set to scan.
+ /// The texture area whose source frame is being resolved.
+ /// The full parent rectangle that should match one frame in the set.
+ /// The matching tolerance for rectangle comparison.
+ /// The best matching frame, or when no acceptable match exists.
+ public static AnimatedTextureFrame? FindBestMatchingAnimatedFrame(AnimatedTextureSet set, TextureArea texture, Rectangle2 parentRect, float margin)
+ {
+ AnimatedTextureFrame? bestFrame = null;
+ float bestScore = float.MaxValue;
+
+ foreach (var frame in set.Frames)
+ {
+ if (!AreEquivalentTextures(frame.Texture, texture.Texture))
+ continue;
+
+ var frameRect = Rectangle2.FromCoordinates(frame.TexCoord0, frame.TexCoord1, frame.TexCoord2, frame.TexCoord3);
+
+ if (!RectanglesMatch(frameRect, parentRect, margin))
+ continue;
+
+ var score = GetRectangleMatchScore(frameRect, parentRect);
+
+ if (score >= bestScore)
+ continue;
+
+ bestScore = score;
+ bestFrame = frame;
+
+ if (score == 0.0f)
+ break;
+ }
+
+ return bestFrame;
+ }
+
+ ///
+ /// Rebuilds a texture area so its UVs cover the full stored parent area instead of the current sub-area.
+ ///
+ /// The texture area whose parent bounds should become the full UV rectangle.
+ /// A copy of expanded to its full parent area.
+ public static TextureArea CreateFullParentAreaTexture(TextureArea texture)
+ {
+ TextureArea fullTexture = texture;
+ fullTexture.TexCoord0 = new Vector2(texture.ParentArea.X0, texture.ParentArea.Y0);
+ fullTexture.TexCoord1 = new Vector2(texture.ParentArea.X0, texture.ParentArea.Y1);
+ fullTexture.TexCoord2 = new Vector2(texture.ParentArea.X1, texture.ParentArea.Y1);
+ fullTexture.TexCoord3 = new Vector2(texture.ParentArea.X1, texture.ParentArea.Y0);
+ fullTexture.ParentArea = Rectangle2.Zero;
+ return fullTexture;
+ }
+
+ ///
+ /// Creates a synthetic animated texture set whose frames are cropped to the same relative sub-area as the input texture.
+ ///
+ /// The source animated texture set.
+ /// The texture area that defines the desired sub-area.
+ /// The full parent rectangle expected to match a frame in .
+ /// The actual sub-rectangle that should be projected onto every frame.
+ /// The matching tolerance for resolving the source frame.
+ /// Receives the generated sub-area animation set when the method succeeds.
+ /// when a valid sub-area animation set was generated; otherwise .
+ public static bool TryCreateSubAreaAnimationSet(
+ AnimatedTextureSet originalSet,
+ TextureArea texture,
+ Rectangle2 parentRect,
+ Rectangle2 subRect,
+ float margin,
+ [NotNullWhen(true)] out AnimatedTextureSet? subSet)
+ {
+ subSet = null;
+
+ AnimatedTextureFrame? matchedFrame = FindBestMatchingAnimatedFrame(originalSet, texture, parentRect, margin);
+
+ if (matchedFrame is null)
+ return false;
+
+ var matchedFrameRect = Rectangle2.FromCoordinates(matchedFrame.TexCoord0, matchedFrame.TexCoord1, matchedFrame.TexCoord2, matchedFrame.TexCoord3);
+
+ if (matchedFrameRect.Width == 0 || matchedFrameRect.Height == 0)
+ return false;
+
+ float relX0 = (subRect.X0 - matchedFrameRect.X0) / matchedFrameRect.Width;
+ float relY0 = (subRect.Y0 - matchedFrameRect.Y0) / matchedFrameRect.Height;
+ float relX1 = (subRect.X1 - matchedFrameRect.X0) / matchedFrameRect.Width;
+ float relY1 = (subRect.Y1 - matchedFrameRect.Y0) / matchedFrameRect.Height;
+
+ subSet = originalSet.Clone();
+
+ foreach (var subFrame in subSet.Frames)
+ {
+ var frameRect = Rectangle2.FromCoordinates(subFrame.TexCoord0, subFrame.TexCoord1, subFrame.TexCoord2, subFrame.TexCoord3);
+
+ float frameWidth = frameRect.Width;
+ float frameHeight = frameRect.Height;
+
+ subFrame.TexCoord0 = new Vector2(frameRect.X0 + relX0 * frameWidth, frameRect.Y0 + relY0 * frameHeight);
+ subFrame.TexCoord1 = new Vector2(frameRect.X0 + relX0 * frameWidth, frameRect.Y0 + relY1 * frameHeight);
+ subFrame.TexCoord2 = new Vector2(frameRect.X0 + relX1 * frameWidth, frameRect.Y0 + relY1 * frameHeight);
+ subFrame.TexCoord3 = new Vector2(frameRect.X0 + relX1 * frameWidth, frameRect.Y0 + relY0 * frameHeight);
+ }
+
+ return true;
+ }
+ }
+
+ internal readonly struct SubAreaKey : IEquatable
+ {
+ private readonly int _destinationKey;
+
+ public readonly Texture Texture;
+ public readonly Rectangle2 ParentRect;
+ public readonly Rectangle2 SubRect;
+
+ ///
+ /// Creates a cache key for a sub-area lookup that does not vary by texture destination.
+ ///
+ /// The texture identity to track.
+ /// The full parent rectangle of the animated frame.
+ /// The cropped sub-area rectangle.
+ /// The rectangle quantization step used for lookup deduplication.
+ public SubAreaKey(Texture texture, Rectangle2 parentRect, Rectangle2 subRect, float margin)
+ : this(texture, 0, parentRect, subRect, margin)
+ { }
+
+ ///
+ /// Creates a cache key for a sub-area lookup scoped to the classic room versus object destination split.
+ ///
+ /// The texture identity to track.
+ /// for room textures; for object textures.
+ /// The full parent rectangle of the animated frame.
+ /// The cropped sub-area rectangle.
+ /// The rectangle quantization step used for lookup deduplication.
+ public SubAreaKey(Texture texture, bool isForRoom, Rectangle2 parentRect, Rectangle2 subRect, float margin)
+ : this(texture, isForRoom ? 1 : 2, parentRect, subRect, margin)
+ { }
+
+ ///
+ /// Creates a cache key for a sub-area lookup scoped to a specific texture destination.
+ ///
+ /// The texture identity to track.
+ /// The destination bucket that the generated lookup belongs to.
+ /// The full parent rectangle of the animated frame.
+ /// The cropped sub-area rectangle.
+ /// The rectangle quantization step used for lookup deduplication.
+ public SubAreaKey(Texture texture, TextureDestination destination, Rectangle2 parentRect, Rectangle2 subRect, float margin)
+ : this(texture, (int)destination + 1, parentRect, subRect, margin)
+ { }
+
+ private SubAreaKey(Texture texture, int destinationKey, Rectangle2 parentRect, Rectangle2 subRect, float margin)
+ {
+ Texture = texture;
+ _destinationKey = destinationKey;
+ ParentRect = AnimatedTextureLookupUtility.NormalizeLookupRectangle(parentRect, margin);
+ SubRect = AnimatedTextureLookupUtility.NormalizeLookupRectangle(subRect, margin);
+ }
+
+ ///
+ /// Compares two sub-area keys using normalized bounds, destination scope, and logical texture identity.
+ ///
+ /// The key to compare against.
+ /// when both keys refer to the same deduplicated sub-area lookup.
+ public bool Equals(SubAreaKey other)
+ {
+ if (_destinationKey != other._destinationKey || ParentRect != other.ParentRect || SubRect != other.SubRect)
+ return false;
+
+ return AnimatedTextureLookupUtility.AreEquivalentTextures(Texture, other.Texture);
+ }
+
+ public override bool Equals(object? obj) => obj is SubAreaKey key && Equals(key);
+
+ public override int GetHashCode()
+ => HashCode.Combine(_destinationKey, ParentRect, SubRect, AnimatedTextureLookupUtility.GetTextureIdentityHash(Texture));
+ }
+}
diff --git a/TombLib/TombLib/LevelData/Compilers/TombEngine/TombEngineTexInfoManager.cs b/TombLib/TombLib/LevelData/Compilers/TombEngine/TombEngineTexInfoManager.cs
index 073330ce4a..685499b410 100644
--- a/TombLib/TombLib/LevelData/Compilers/TombEngine/TombEngineTexInfoManager.cs
+++ b/TombLib/TombLib/LevelData/Compilers/TombEngine/TombEngineTexInfoManager.cs
@@ -74,6 +74,8 @@ private static HashSet GetCandidateSet()
private List _referenceAnimTextures = new List();
private List _actualAnimTextures = new List();
+ private HashSet _processedSubAreas = new HashSet();
+
// UVRotate count should be placed after anim texture data to identify how many first anim seqs
// should be processed using UVRotate engine function
@@ -1257,13 +1259,53 @@ public Result AddTexture(TextureArea texture, TextureDestination destination, bo
{
GenerateAnimTexture(refTex, refQuad, destination, isForTriangle);
var result = AddTexture(texture, destination, isForTriangle, blendMode);
- {
- result.Animated = true;
- }
return new Result() { ConvertToQuad = false, Rotation = result.Rotation, TexInfoIndex = result.TexInfoIndex, Animated = true };
}
}
+ // Check if this is a sub-area of an animated texture (e.g. applied via group texturing tools).
+ // In this case, the actual UV coordinates represent a portion of the full animation frame,
+ // so we reconstruct the full frame from ParentArea and match against reference animations.
+ if (!texture.ParentArea.IsZero && _referenceAnimTextures.Count > 0 &&
+ texture.ParentArea != texture.GetRect(isForTriangle))
+ {
+ TextureArea fullTexture = AnimatedTextureLookupUtility.CreateFullParentAreaTexture(texture);
+ int initialReferenceAnimTextureCount = _referenceAnimTextures.Count;
+
+ for (int i = 0; i < initialReferenceAnimTextureCount; i++)
+ {
+ var refTex = _referenceAnimTextures[i];
+
+ // UVRotate and Video animation types are incompatible with sub-area splitting
+ // because they rely on specific frame arrangement assumptions (vertical strip scrolling
+ // for UVRotate, sequential frame playback for Video) that break when coordinates
+ // are transformed to sub-areas.
+ if (refTex.Origin.IsUvRotate || refTex.Origin.AnimationType == AnimatedTextureAnimationType.Video)
+ continue;
+
+ if (GetTexInfo(fullTexture, refTex.CompiledAnimation, destination, false, blendMode, false, _animTextureLookupMargin).HasValue)
+ {
+ var origSet = refTex.Origin;
+ var parentRect = texture.ParentArea;
+ var subRect = texture.GetRect(isForTriangle);
+
+ // Skip if this sub-area was already processed for this texture and destination
+ if (!_processedSubAreas.Add(new SubAreaKey(texture.Texture, destination, parentRect, subRect, _animTextureLookupMargin)))
+ continue;
+
+ if (!AnimatedTextureLookupUtility.TryCreateSubAreaAnimationSet(origSet, texture, parentRect, subRect, _animTextureLookupMargin, out var subSet))
+ continue;
+
+ // Generate reference lookups for this sub-area animation set while preserving
+ // the original animated set identity for downstream metadata consumers.
+ GenerateAnimLookups(new List { subSet }, destination, origSet);
+
+ // Retry - the sub-area coordinates should now match the new reference lookups
+ return AddTexture(texture, destination, isForTriangle, blendMode);
+ }
+ }
+ }
+
var parentTextures = _parentRoomTextureAreas;
if (destination == TextureDestination.Moveable)
parentTextures = _parentMoveableTextureAreas;
@@ -1338,7 +1380,7 @@ private void SortOutAlpha(List parentList)
// Generates list of dummy lookup animated textures.
- private void GenerateAnimLookups(List sets, TextureDestination destination)
+ private void GenerateAnimLookups(List sets, TextureDestination destination, AnimatedTextureSet exportOriginOverride = null)
{
foreach (var set in sets)
{
@@ -1357,7 +1399,7 @@ private void GenerateAnimLookups(List sets, TextureDestinati
while (true)
{
- var refAnim = new ParentAnimatedTexture(set);
+ var refAnim = new ParentAnimatedTexture(exportOriginOverride ?? set);
int index = 0;
foreach (var frame in set.Frames)
diff --git a/TombLib/TombLib/LevelData/Compilers/Util/TexInfoManager.cs b/TombLib/TombLib/LevelData/Compilers/Util/TexInfoManager.cs
index b1cd775e30..e5362ced2a 100644
--- a/TombLib/TombLib/LevelData/Compilers/Util/TexInfoManager.cs
+++ b/TombLib/TombLib/LevelData/Compilers/Util/TexInfoManager.cs
@@ -10,6 +10,7 @@
using System.Numerics;
using System.Threading.Tasks;
using TombLib.IO;
+using TombLib.LevelData.Compilers;
using TombLib.Utils;
using TombLib.Wad;
@@ -53,6 +54,8 @@ public class TexInfoManager
private List _referenceAnimTextures = new List();
private List _actualAnimTextures = new List();
+ private HashSet _processedSubAreas = new HashSet();
+
// UVRotate count should be placed after anim texture data to identify how many first anim seqs
// should be processed using UVRotate engine function
@@ -568,7 +571,7 @@ public TexInfoManager(Level level, IProgressReporter progressReporter, int maxTi
MaxTileSize = _minimumTileSize;
}
- GenerateAnimLookups(_level.Settings.AnimatedTextureSets); // Generate anim texture lookup table
+ GenerateAnimLookups(_level.Settings.AnimatedTextureSets, true); // Generate anim texture lookup table
_generateTexInfos = true; // Set manager ready state
}
@@ -830,6 +833,49 @@ public Result AddTexture(TextureArea texture, bool isForRoom, bool isForTriangle
}
}
+ // Check if this is a sub-area of an animated texture (e.g. applied via group texturing tools).
+ // In this case, the actual UV coordinates represent a portion of the full animation frame,
+ // so we reconstruct the full frame from ParentArea and match against reference animations.
+ if (!texture.ParentArea.IsZero && _referenceAnimTextures.Count > 0 &&
+ texture.ParentArea != texture.GetRect(isForTriangle))
+ {
+ TextureArea fullTexture = AnimatedTextureLookupUtility.CreateFullParentAreaTexture(texture);
+ int initialReferenceAnimTextureCount = _referenceAnimTextures.Count;
+
+ for (int i = 0; i < initialReferenceAnimTextureCount; i++)
+ {
+ var refTex = _referenceAnimTextures[i];
+
+ // UVRotate and Video animation types are incompatible with sub-area splitting
+ // because they rely on specific frame arrangement assumptions (vertical strip scrolling
+ // for UVRotate, sequential frame playback for Video) that break when coordinates
+ // are transformed to sub-areas.
+ if (refTex.Origin.IsUvRotate || refTex.Origin.AnimationType == AnimatedTextureAnimationType.Video)
+ continue;
+
+ if (GetTexInfo(fullTexture, refTex.CompiledAnimation, isForRoom, false, false, false, remapAnimatedTextures, _animTextureLookupMargin).HasValue)
+ {
+ var origSet = refTex.Origin;
+ var parentRect = texture.ParentArea;
+ var subRect = texture.GetRect(isForTriangle);
+
+ // Skip if this sub-area was already processed for this texture
+ if (!_processedSubAreas.Add(new SubAreaKey(texture.Texture, isForRoom, parentRect, subRect, _animTextureLookupMargin)))
+ continue;
+
+ if (!AnimatedTextureLookupUtility.TryCreateSubAreaAnimationSet(origSet, texture, parentRect, subRect, _animTextureLookupMargin, out var subSet))
+ continue;
+
+ // Generate reference lookups for this sub-area animation set while preserving
+ // the original animated set identity for downstream exporters such as TRNG.
+ GenerateAnimLookups(new List { subSet }, isForRoom, origSet);
+
+ // Retry - the sub-area coordinates should now match the new reference lookups
+ return AddTexture(texture, isForRoom, isForTriangle, topmostAndUnpadded);
+ }
+ }
+ }
+
// No animated textures identified, add texture as ordinary one
return AddTexture(texture, _parentTextures, isForRoom, isForTriangle, topmostAndUnpadded);
}
@@ -899,7 +945,7 @@ private void SortOutAlpha(TRVersion.Game version, List parent
// Generates list of dummy lookup animated textures.
- private void GenerateAnimLookups(List sets)
+ private void GenerateAnimLookups(List sets, bool isForRoom, AnimatedTextureSet exportOriginOverride = null)
{
foreach (var set in sets)
{
@@ -918,7 +964,7 @@ private void GenerateAnimLookups(List sets)
while (true)
{
- var refAnim = new ParentAnimatedTexture(set);
+ var refAnim = new ParentAnimatedTexture(exportOriginOverride ?? set);
int index = 0;
foreach (var frame in set.Frames)
@@ -966,7 +1012,7 @@ private void GenerateAnimLookups(List sets)
// Make frame, including repeat versions
for (int i = 0; i < frame.Repeat; i++)
{
- AddTexture(newFrame, refAnim.CompiledAnimation, true, (triangleVariation > 0), set.AnimationType == AnimatedTextureAnimationType.UVRotate, index, set.IsUvRotate);
+ AddTexture(newFrame, refAnim.CompiledAnimation, isForRoom, (triangleVariation > 0), set.AnimationType == AnimatedTextureAnimationType.UVRotate, index, set.IsUvRotate);
index++;
}
}