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++; } }