From de157c44e8e7f135cb3e60c871d365a71962296b Mon Sep 17 00:00:00 2001 From: Andreas Loew Date: Mon, 4 Aug 2025 18:04:25 +0200 Subject: [PATCH] Texture2DRegion: support trimmed and rotated sprites optionally load origin from atlas data file (#1004) * Added the following features to Texture2DRegion to improve sprite sheet packing efficiency: - Regions on the texture can now be rotated. - Transparent borders around sprites can be trimmed. The SpriteBatch.Draw method will automatically rotate the region back to its original orientation and restore the transparent borders during rendering. Additionally, since sprite sheet data files can define sprite origins, TextureRegion2D can now optionally store this origin. Sprites created from a Texture2DRegion will use this stored origin as their default. * Adapted nine-patch sprite implementation for support rotated / trimmed sprites, too. * Extended the TextureAtlas content pipeline to support trimmed and rotated sprites, as well as reading anchor points (origins) from the data file. The JSON format has been cleaned up by removing unnecessary keys. The legacy format remains supported for backward compatibility. * added build-time check to ensure that texture image file referenced by atlas data file exists --- .../TexturePackerJsonImporter.cs | 15 +- .../TextureAtlases/TexturePackerProcessor.cs | 17 +++ .../TextureAtlases/TexturePackerWriter.cs | 69 ++++++++-- .../ContentReaders/Texture2DAtlasReader.cs | 34 ++++- .../TexturePacker/TexturePackerFileContent.cs | 29 +++- .../MonoGame.Extended/Graphics/NinePatch.cs | 4 +- source/MonoGame.Extended/Graphics/Sprite.cs | 18 ++- .../Graphics/SpriteBatch.Extensions.cs | 130 ++++++++++++------ .../Graphics/Texture2DAtlas.cs | 17 +++ .../Graphics/Texture2DRegion.Extensions.cs | 56 ++++++-- .../Graphics/Texture2DRegion.cs | 50 +++++++ ...ame.Extended.Content.Pipeline.Tests.csproj | 1 + .../TestData/test-atlas.json | 120 ++++++++++++++++ ...TexturePackerJsonImporterProcessorTests.cs | 63 +++++++++ 14 files changed, 549 insertions(+), 74 deletions(-) create mode 100644 tests/MonoGame.Extended.Content.Pipeline.Tests/TestData/test-atlas.json diff --git a/source/MonoGame.Extended.Content.Pipeline/TextureAtlases/TexturePackerJsonImporter.cs b/source/MonoGame.Extended.Content.Pipeline/TextureAtlases/TexturePackerJsonImporter.cs index 7ea3d551..c89c9f50 100644 --- a/source/MonoGame.Extended.Content.Pipeline/TextureAtlases/TexturePackerJsonImporter.cs +++ b/source/MonoGame.Extended.Content.Pipeline/TextureAtlases/TexturePackerJsonImporter.cs @@ -13,7 +13,20 @@ namespace MonoGame.Extended.Content.Pipeline.TextureAtlases public override TexturePackerFileContent Import(string filename, ContentImporterContext context) { var tpFile = TexturePackerFileReader.Read(filename); - context.AddDependency(tpFile.Meta.Image); + + if (tpFile.Meta.Image != null) + { + context.AddDependency(tpFile.Meta.Image); + } + else if (tpFile.Meta.DataFormat == "monogame-extended") + { + // new format: textures are in the textures array + foreach (var texture in tpFile.Textures) + { + context.AddDependency(texture.FileName); + } + } + return tpFile; } } diff --git a/source/MonoGame.Extended.Content.Pipeline/TextureAtlases/TexturePackerProcessor.cs b/source/MonoGame.Extended.Content.Pipeline/TextureAtlases/TexturePackerProcessor.cs index 13e1d227..99b0c16e 100644 --- a/source/MonoGame.Extended.Content.Pipeline/TextureAtlases/TexturePackerProcessor.cs +++ b/source/MonoGame.Extended.Content.Pipeline/TextureAtlases/TexturePackerProcessor.cs @@ -3,6 +3,8 @@ // See LICENSE file in the project root for full license information. using Microsoft.Xna.Framework.Content.Pipeline; +using Microsoft.Xna.Framework.Content.Pipeline.Graphics; +using Microsoft.Xna.Framework.Content.Pipeline.Processors; using MonoGame.Extended.Content.TexturePacker; namespace MonoGame.Extended.Content.Pipeline.TextureAtlases; @@ -12,6 +14,21 @@ public class TexturePackerProcessor : ContentProcessor(input.Meta.Image); + context.BuildAndLoadAsset(externalRef, nameof(TextureProcessor)); + + } + else if (input.Meta.DataFormat == "monogame-extended") + { + foreach (var texture in input.Textures) + { + var externalRef = new ExternalReference(texture.FileName); + context.BuildAndLoadAsset(externalRef, nameof(TextureProcessor)); + } + } return new TexturePackerProcessorResult(input); } } diff --git a/source/MonoGame.Extended.Content.Pipeline/TextureAtlases/TexturePackerWriter.cs b/source/MonoGame.Extended.Content.Pipeline/TextureAtlases/TexturePackerWriter.cs index 9ee763cf..39264209 100644 --- a/source/MonoGame.Extended.Content.Pipeline/TextureAtlases/TexturePackerWriter.cs +++ b/source/MonoGame.Extended.Content.Pipeline/TextureAtlases/TexturePackerWriter.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. // See LICENSE file in the project root for full license information. +using System; using System.IO; using Microsoft.Xna.Framework.Content.Pipeline; using Microsoft.Xna.Framework.Content.Pipeline.Serialization.Compiler; @@ -15,20 +16,66 @@ public class TexturePackerWriter : ContentTypeWriter Regions, + [property: JsonPropertyName("textures")] List Textures, [property: JsonPropertyName("meta")] TexturePackerMeta Meta); -public record TexturePackerPoint([property: JsonPropertyName("x")] double X, - [property: JsonPropertyName("y")] double Y); + +public record TexturePackerTexture([property: JsonPropertyName("filename")] string FileName, + [property: JsonPropertyName("format")] string? Format = null, + [property: JsonPropertyName("size")] TexturePackerSize Size = default, + [property: JsonPropertyName("scale")] string? Scale = null, + [property: JsonPropertyName("premultiplied")] bool Premultiplied = false, + [property: JsonPropertyName("frames")] Dictionary? Frames = null +); + +public record TexturePackerTextureFrame([property: JsonPropertyName("frame")] TexturePackerRectangle Frame, + [property: JsonPropertyName("rotated")] int Rotated = 0, + [property: JsonPropertyName("size")] TexturePackerSize? Size = null, + [property: JsonPropertyName("offset")] TexturePackerPoint? Offset = null, + [property: JsonPropertyName("pivot")] TexturePackerPointF? Pivot = null, + [property: JsonPropertyName("scale9")] TexturePackerRectangle? Scale9 = null +); +public record TexturePackerPoint([property: JsonPropertyName("x")] int X, + [property: JsonPropertyName("y")] int Y); +public record TexturePackerPointF([property: JsonPropertyName("x")] double X, + [property: JsonPropertyName("y")] double Y); public record TexturePackerSize([property: JsonPropertyName("w")] int Width, [property: JsonPropertyName("h")] int Height); @@ -22,12 +41,10 @@ public record TexturePackerFrame([property: JsonPropertyName("filename")] string [property: JsonPropertyName("trimmed")] bool Trimmed, [property: JsonPropertyName("spriteSourceSize")] TexturePackerRectangle SpriteSourceSize, [property: JsonPropertyName("sourceSize")] TexturePackerSize SourceSize, - [property: JsonPropertyName("pivot")] TexturePackerPoint PivotPoint); + [property: JsonPropertyName("pivot")] TexturePackerPointF PivotPoint); public record TexturePackerMeta([property: JsonPropertyName("app")] string App, [property: JsonPropertyName("version")] string Version, [property: JsonPropertyName("image")] string Image, - [property: JsonPropertyName("format")] string Format, - [property: JsonPropertyName("size")] TexturePackerSize Size, - [property: JsonPropertyName("scale"), JsonConverter(typeof(FloatStringConverter))] float Scale, + [property: JsonPropertyName("dataformat")] string DataFormat, [property: JsonPropertyName("smartupdate")] string SmartUpdate); diff --git a/source/MonoGame.Extended/Graphics/NinePatch.cs b/source/MonoGame.Extended/Graphics/NinePatch.cs index 7a9e1069..fcdf883b 100644 --- a/source/MonoGame.Extended/Graphics/NinePatch.cs +++ b/source/MonoGame.Extended/Graphics/NinePatch.cs @@ -121,8 +121,8 @@ public class NinePatch _patches = patches; - Size topLeft = patches[NinePatch.TopLeft].Size; - Size bottomRight = patches[NinePatch.BottomRight].Size; + Size topLeft = patches[NinePatch.TopLeft].OriginalSize; + Size bottomRight = patches[NinePatch.BottomRight].OriginalSize; Padding = new Thickness(topLeft.Width, topLeft.Height, bottomRight.Width, bottomRight.Height); Name = name; diff --git a/source/MonoGame.Extended/Graphics/Sprite.cs b/source/MonoGame.Extended/Graphics/Sprite.cs index d21623b8..5334504b 100644 --- a/source/MonoGame.Extended/Graphics/Sprite.cs +++ b/source/MonoGame.Extended/Graphics/Sprite.cs @@ -61,6 +61,11 @@ public class Sprite : IColorable /// public object Tag { get; set; } + /// + /// Gets the size of the sprite. + /// + public Point Size => TextureRegion.OriginalSize; + /// /// Gets or sets the origin of this sprite. /// @@ -79,8 +84,8 @@ public class Sprite : IColorable /// public Vector2 OriginNormalized { - get { return new Vector2(Origin.X / TextureRegion.Width, Origin.Y / TextureRegion.Height); } - set { Origin = new Vector2(value.X * TextureRegion.Width, value.Y * TextureRegion.Height); } + get { return new Vector2(Origin.X / TextureRegion.OriginalSize.Width, Origin.Y / TextureRegion.OriginalSize.Height); } + set { Origin = new Vector2(value.X * TextureRegion.OriginalSize.Width, value.Y * TextureRegion.OriginalSize.Height); } } /// @@ -101,6 +106,11 @@ public class Sprite : IColorable throw new ObjectDisposedException(nameof(value), $"The source {nameof(Texture2D)} of the {nameof(TextureRegion)} was disposed prior to setting this property."); } _textureRegion = value; + + if (value.OriginNormalized.HasValue) + { + OriginNormalized = value.OriginNormalized.Value; + } } } @@ -145,7 +155,7 @@ public class Sprite : IColorable Color = Color.White; IsVisible = true; Effect = SpriteEffects.None; - OriginNormalized = Vector2.Zero; + OriginNormalized = textureRegion.OriginNormalized ?? Vector2.Zero; Depth = 0.0f; } @@ -184,7 +194,7 @@ public class Sprite : IColorable public Vector2[] GetCorners(Vector2 position, float rotation, Vector2 scale) { var min = -Origin; - var max = min + new Vector2(TextureRegion.Width, TextureRegion.Height); + var max = min + new Vector2(TextureRegion.OriginalSize.Width, TextureRegion.OriginalSize.Height); var offset = position; if (scale != Vector2.One) diff --git a/source/MonoGame.Extended/Graphics/SpriteBatch.Extensions.cs b/source/MonoGame.Extended/Graphics/SpriteBatch.Extensions.cs index 63b7e225..2f685210 100644 --- a/source/MonoGame.Extended/Graphics/SpriteBatch.Extensions.cs +++ b/source/MonoGame.Extended/Graphics/SpriteBatch.Extensions.cs @@ -32,21 +32,17 @@ public static class SpriteBatchExtensions for (int i = 0; i < sourcePatches.Length; i++) { - Rectangle source = sourcePatches[i].Bounds; - Rectangle destination = _patchCache[i]; + Texture2DRegion sourceRegion = sourcePatches[i]; + Rectangle destinationRect = _patchCache[i]; if (clippingRectangle.HasValue) { - source = ClipSourceRectangle(source, destination, clippingRectangle.Value); - destination = ClipDestinationRectangle(destination, clippingRectangle.Value); - Draw(spriteBatch, sourcePatches[i].Texture, source, destination, color, clippingRectangle); + sourceRegion = ClipSourceRegion(sourceRegion, destinationRect, clippingRectangle.Value); + destinationRect = ClipDestinationRectangle(destinationRect, clippingRectangle.Value); } - else + if (sourceRegion != null && !destinationRect.IsEmpty) { - if (destination.Width > 0 && destination.Height > 0) - { - spriteBatch.Draw(sourcePatches[i].Texture, destination, source, color); - } + Draw(spriteBatch, sourceRegion, destinationRect, color); } } } @@ -104,9 +100,17 @@ public static class SpriteBatchExtensions if (sprite.IsVisible) { - var texture = sprite.TextureRegion.Texture; - var sourceRectangle = sprite.TextureRegion.Bounds; - spriteBatch.Draw(texture, position, sourceRectangle, sprite.Color * sprite.Alpha, rotation, sprite.Origin, scale, sprite.Effect, sprite.Depth); + Draw( + spriteBatch, + sprite.TextureRegion, + position, + sprite.Color * sprite.Alpha, + rotation, + sprite.Origin, + scale, + sprite.Effect, + sprite.Depth + ); } } #endregion -------------------------Sprite----------------------------- @@ -164,29 +168,49 @@ public static class SpriteBatchExtensions /// The layer depth. /// An optional clipping rectangle. public static void Draw(this SpriteBatch spriteBatch, Texture2DRegion textureRegion, Vector2 position, Color color, - float rotation, Vector2 origin, Vector2 scale, SpriteEffects effects, float layerDepth, Rectangle? clippingRectangle = null) + float rotation, Vector2 origin, Vector2 scale, SpriteEffects effects, float layerDepth, Rectangle? clippingRectangle = null) { var sourceRectangle = textureRegion.Bounds; + var offset = (textureRegion.Offset - origin) * scale; + Vector2 drawScale = scale; + float drawRotation = rotation; + + // Handle rotated texture regions + if (textureRegion.IsRotated) + { + offset.Y += textureRegion.Width * scale.Y; + + // Swap scale axes and adjust rotation for rotated regions + drawScale = new Vector2(scale.Y, scale.X); + drawRotation = rotation - (float)Math.PI / 2; + } + + var drawPosition = position + RotateVector(offset, rotation); if (clippingRectangle.HasValue) { - var x = (int)(position.X - origin.X); - var y = (int)(position.Y - origin.Y); - var width = (int)(textureRegion.Width * scale.X); - var height = (int)(textureRegion.Height * scale.Y); + var x = (int)drawPosition.X; + var y = (int)drawPosition.Y; + var width = (int)(textureRegion.Width * drawScale.X); + var height = (int)(textureRegion.Height * drawScale.Y); + if (textureRegion.IsRotated) + { + (width, height) = (height, width); + y -= height; + } var destinationRectangle = new Rectangle(x, y, width, height); - if (!ClipRectangles(ref sourceRectangle, ref destinationRectangle, clippingRectangle)) + if (!ClipRectangles(ref sourceRectangle, ref destinationRectangle, clippingRectangle, textureRegion.IsRotated)) { // Clipped rectangle is empty, nothing to draw return; } - - position.X = destinationRectangle.X + origin.X; - position.Y = destinationRectangle.Y + origin.Y; + drawPosition.X = destinationRectangle.X; + drawPosition.Y = textureRegion.IsRotated ? destinationRectangle.Y + destinationRectangle.Height + : destinationRectangle.Y; } - spriteBatch.Draw(textureRegion.Texture, position, sourceRectangle, color, rotation, origin, scale, effects, layerDepth); + spriteBatch.Draw(textureRegion.Texture, drawPosition, sourceRectangle, color, drawRotation, Vector2.Zero, drawScale, effects, layerDepth); } /// @@ -199,7 +223,9 @@ public static class SpriteBatchExtensions /// An optional clipping rectangle. public static void Draw(this SpriteBatch spriteBatch, Texture2DRegion textureRegion, Rectangle destinationRectangle, Color color, Rectangle? clippingRectangle = null) { - Draw(spriteBatch, textureRegion.Texture, textureRegion.Bounds, destinationRectangle, color, clippingRectangle); + float scaleX = (float)destinationRectangle.Width / textureRegion.OriginalSize.Width; + float scaleY = (float)destinationRectangle.Height / textureRegion.OriginalSize.Height; + Draw(spriteBatch, textureRegion, new Vector2(destinationRectangle.X, destinationRectangle.Y), color, 0, Vector2.Zero, new Vector2(scaleX, scaleY), SpriteEffects.None, 0, clippingRectangle); } #endregion -------------------------TextureRegion----------------------------- @@ -227,7 +253,7 @@ public static class SpriteBatchExtensions _patchCache[NinePatch.BottomMiddle] = new Rectangle(left, bottom, midWidth, bottomPadding); _patchCache[NinePatch.BottomRight] = new Rectangle(right, bottom, rightPadding, bottomPadding); } - private static bool ClipRectangles(ref Rectangle sourceRectangle, ref Rectangle destinationRectangle, Rectangle? clippingRectangle) + private static bool ClipRectangles(ref Rectangle sourceRectangle, ref Rectangle destinationRectangle, Rectangle? clippingRectangle, bool rotatedSource = false) { if (!clippingRectangle.HasValue) return true; @@ -238,21 +264,35 @@ public static class SpriteBatchExtensions if (destinationRectangle == Rectangle.Empty) return false; // Clipped rectangle is empty, nothing to draw - var scaleX = (float)sourceRectangle.Width / originalDestination.Width; - var scaleY = (float)sourceRectangle.Height / originalDestination.Height; - int leftDiff = destinationRectangle.Left - originalDestination.Left; int topDiff = destinationRectangle.Top - originalDestination.Top; + int bottomDiff = originalDestination.Bottom - destinationRectangle.Bottom; - sourceRectangle.X += (int)(leftDiff * scaleX); - sourceRectangle.Y += (int)(topDiff * scaleY); - sourceRectangle.Width = (int)(destinationRectangle.Width * scaleX); - sourceRectangle.Height = (int)(destinationRectangle.Height * scaleY); + if (rotatedSource) + { + var scaleX = (float)sourceRectangle.Height / originalDestination.Width; + var scaleY = (float)sourceRectangle.Width / originalDestination.Height; + + sourceRectangle.X += (int)(bottomDiff * scaleY); + sourceRectangle.Y += (int)(leftDiff * scaleX); + sourceRectangle.Width = (int)(destinationRectangle.Height * scaleY); + sourceRectangle.Height = (int)(destinationRectangle.Width * scaleX); + } + else + { + var scaleX = (float)sourceRectangle.Width / originalDestination.Width; + var scaleY = (float)sourceRectangle.Height / originalDestination.Height; + + sourceRectangle.X += (int)(leftDiff * scaleX); + sourceRectangle.Y += (int)(topDiff * scaleY); + sourceRectangle.Width = (int)(destinationRectangle.Width * scaleX); + sourceRectangle.Height = (int)(destinationRectangle.Height * scaleY); + } return true; } - private static Rectangle ClipSourceRectangle(Rectangle sourceRectangle, Rectangle destinationRectangle, Rectangle clippingRectangle) + private static Texture2DRegion ClipSourceRegion(Texture2DRegion sourceRegion, Rectangle destinationRectangle, Rectangle clippingRectangle) { var left = (float)(clippingRectangle.Left - destinationRectangle.Left); var right = (float)(destinationRectangle.Right - clippingRectangle.Right); @@ -263,23 +303,31 @@ public static class SpriteBatchExtensions var w = (right > 0 ? right : 0) + x; var h = (bottom > 0 ? bottom : 0) + y; - var scaleX = (float)destinationRectangle.Width / sourceRectangle.Width; - var scaleY = (float)destinationRectangle.Height / sourceRectangle.Height; + var scaleX = (float)destinationRectangle.Width / sourceRegion.OriginalSize.Width; + var scaleY = (float)destinationRectangle.Height / sourceRegion.OriginalSize.Height; x /= scaleX; y /= scaleY; w /= scaleX; h /= scaleY; - return new Rectangle((int)(sourceRectangle.X + x), (int)(sourceRectangle.Y + y), (int)(sourceRectangle.Width - w), (int)(sourceRectangle.Height - h)); + return sourceRegion.GetSubregion((int)x, (int)y, (int)(sourceRegion.OriginalSize.Width - w), (int)(sourceRegion.OriginalSize.Height - h)); } private static Rectangle ClipDestinationRectangle(Rectangle destinationRectangle, Rectangle clippingRectangle) { - var left = clippingRectangle.Left < destinationRectangle.Left ? destinationRectangle.Left : clippingRectangle.Left; - var top = clippingRectangle.Top < destinationRectangle.Top ? destinationRectangle.Top : clippingRectangle.Top; - var bottom = clippingRectangle.Bottom < destinationRectangle.Bottom ? clippingRectangle.Bottom : destinationRectangle.Bottom; - var right = clippingRectangle.Right < destinationRectangle.Right ? clippingRectangle.Right : destinationRectangle.Right; - return new Rectangle(left, top, right - left, bottom - top); + return destinationRectangle.Clip(clippingRectangle); + } + + // Rotates a 2D vector by the specified angle in radians. + private static Vector2 RotateVector(Vector2 original, double rotation) + { + double cosTheta = Math.Cos(rotation); + double sinTheta = Math.Sin(rotation); + + double rotatedX = original.X * cosTheta - original.Y * sinTheta; + double rotatedY = original.X * sinTheta + original.Y * cosTheta; + + return new Vector2((float)rotatedX, (float)rotatedY); } #endregion -------------------------Utilities----------------------------- diff --git a/source/MonoGame.Extended/Graphics/Texture2DAtlas.cs b/source/MonoGame.Extended/Graphics/Texture2DAtlas.cs index daf766ba..2a19b5e9 100644 --- a/source/MonoGame.Extended/Graphics/Texture2DAtlas.cs +++ b/source/MonoGame.Extended/Graphics/Texture2DAtlas.cs @@ -174,6 +174,23 @@ public class Texture2DAtlas : IEnumerable return region; } + /// + /// Creates a new texture region with the specified name and adds it to this atlas. + /// + /// The bounds of the region. + /// The name of the texture region. + /// A value indicating whether this texture region is rotated 90 degrees clockwise in the atlas. + /// The created texture region. + /// + /// Thrown if a region with the same name as the parameter already exists in this atlas. + /// + public Texture2DRegion CreateRegion(Rectangle bounds, bool isRotated, Size originalSize, Vector2 trimOffset, Vector2? originNormalized, string name) + { + Texture2DRegion region = new Texture2DRegion(Texture, bounds.X, bounds.Y, bounds.Width, bounds.Height, isRotated, originalSize, trimOffset, originNormalized, name); + AddRegion(region); + return region; + } + /// /// Determines whether the atlas contains a region with the specified name. /// diff --git a/source/MonoGame.Extended/Graphics/Texture2DRegion.Extensions.cs b/source/MonoGame.Extended/Graphics/Texture2DRegion.Extensions.cs index a0fbce90..d10d4885 100644 --- a/source/MonoGame.Extended/Graphics/Texture2DRegion.Extensions.cs +++ b/source/MonoGame.Extended/Graphics/Texture2DRegion.Extensions.cs @@ -65,8 +65,8 @@ public static class Texture2DRegionExtensions /// Gets a subregion of the specified texture region using the provided name, coordinates, and dimensions. /// /// The texture region to get the subregion from. - /// The top-left x-coordinate of the subregion within the texture region. - /// The top-left y-coordinate of the subregion within the texture region. + /// The top-left x-coordinate of the subregion within the original sprite. + /// The top-left y-coordinate of the subregion within the original sprite. /// The width, in pixels, of the subregion. /// The height, in pixels, of the subregion. /// The name of the new subregion. @@ -86,8 +86,47 @@ public static class Texture2DRegionExtensions name = $"{textureRegion.Texture.Name}({x}, {y}, {width}, {height})"; } - Rectangle region = textureRegion.Bounds.GetRelativeRectangle(x, y, width, height); - return new Texture2DRegion(textureRegion.Texture, region, name); + // The requested subregion is in original sprite coordinates + Rectangle requestedRegion = new Rectangle(x, y, width, height); + + // Calculate the bounds of the trimmed region in original sprite coordinates + Rectangle trimmedBounds = new Rectangle( + (int)textureRegion.Offset.X, + (int)textureRegion.Offset.Y, + textureRegion.IsRotated ? textureRegion.Height : textureRegion.Width, + textureRegion.IsRotated ? textureRegion.Width : textureRegion.Height); + + // Find intersection between requested subregion and the actual trimmed region + Rectangle intersection = Rectangle.Intersect(requestedRegion, trimmedBounds); + + if (intersection.IsEmpty) + { + return null; + } + + // The subregion offset must include the existing offset of the input region + Vector2 subregionOffset = new Vector2(Math.Max(0, textureRegion.Offset.X - x), + Math.Max(0, textureRegion.Offset.Y - y)); + + if (textureRegion.IsRotated) + { + // Calculate the actual texture coordinates + int textureX = textureRegion.X + (textureRegion.Width + (int)textureRegion.Offset.Y - intersection.Bottom); + int textureY = textureRegion.Y + (intersection.X - (int)textureRegion.Offset.X); + + return new Texture2DRegion(textureRegion.Texture, textureX, textureY, intersection.Height, intersection.Width, + textureRegion.IsRotated, new Size(width, height), subregionOffset, textureRegion.OriginNormalized, name); + } + else + { + // Calculate the actual texture coordinates + int textureX = textureRegion.X + (intersection.X - (int)textureRegion.Offset.X); + int textureY = textureRegion.Y + (intersection.Y - (int)textureRegion.Offset.Y); + + return new Texture2DRegion(textureRegion.Texture, textureX, textureY, intersection.Width, intersection.Height, + textureRegion.IsRotated, new Size(width, height), subregionOffset, textureRegion.OriginNormalized, name); + + } } /// @@ -139,10 +178,11 @@ public static class Texture2DRegionExtensions { Texture2DRegion[] patches = new Texture2DRegion[9]; - int middleWidth = textureRegion.Width - leftPadding - rightPadding; - int middleHeight = textureRegion.Height - topPadding - bottomPadding; - int rightX = textureRegion.Width - rightPadding; - int bottomY = textureRegion.Height - bottomPadding; + // Use original sprite dimensions for calculations + int middleWidth = textureRegion.OriginalSize.Width - leftPadding - rightPadding; + int middleHeight = textureRegion.OriginalSize.Height - topPadding - bottomPadding; + int rightX = textureRegion.OriginalSize.Width - rightPadding; + int bottomY = textureRegion.OriginalSize.Height - bottomPadding; patches[NinePatch.TopLeft] = textureRegion.GetSubregion(0, 0, leftPadding, topPadding); patches[NinePatch.TopMiddle] = textureRegion.GetSubregion(leftPadding, 0, middleWidth, topPadding); diff --git a/source/MonoGame.Extended/Graphics/Texture2DRegion.cs b/source/MonoGame.Extended/Graphics/Texture2DRegion.cs index aada7d0a..60ab2c52 100644 --- a/source/MonoGame.Extended/Graphics/Texture2DRegion.cs +++ b/source/MonoGame.Extended/Graphics/Texture2DRegion.cs @@ -78,6 +78,26 @@ public class Texture2DRegion /// public float LeftUV { get; } + /// + /// Gets a value indicating whether this texture region is rotated 90 degrees clockwise in the atlas. + /// + public bool IsRotated { get; } + + /// + /// Gets the original size of the texture region before trimming. + /// + public Size OriginalSize { get; } + + /// + /// Gets the offset between the top-left corner of the original sprite and the top-left corner of the trimmed sprite. + /// + public Vector2 Offset { get; } + + /// + /// Gets the normalized origin point of the texture region, or null if no origin is specified. + /// + public Vector2? OriginNormalized { get; } + /// /// Initializes a new instance of the class representing the entire texture. /// @@ -170,6 +190,29 @@ public class Texture2DRegion /// Thrown if has been disposed prior. /// public Texture2DRegion(Texture2D texture,int x, int y, int width, int height, string name) + : this(texture, x, y, width, height, false, new Size(width, height), Vector2.Zero, null, name) { } + + /// + /// Initializes a new instance of the class with the specified region of the texture, + /// original size, offset, origin, and name. + /// + /// The texture to create the region from. + /// The top-left x-coordinate of the region within the texture. + /// The top-left y-coordinate of the region within the texture. + /// The width, in pixels, of the region. + /// The height, in pixels, of the region. + /// A value indicating whether this texture region is rotated 90 degrees clockwise in the atlas. + /// The original size of the texture region before trimming. + /// The offset between the top-left corner of the original sprite and the top-left corner of the trimmed sprite. + /// The origin point of the texture region, or null if no origin is specified. + /// The name of the texture region. + /// + /// Thrown if is . + /// + /// + /// Thrown if has been disposed prior. + /// + public Texture2DRegion(Texture2D texture, int x, int y, int width, int height, bool isRotated, Size originalSize, Vector2 offset, Vector2? originNormalized, string name) { ArgumentNullException.ThrowIfNull(texture); if (texture.IsDisposed) @@ -188,6 +231,10 @@ public class Texture2DRegion Y = y; Width = width; Height = height; + IsRotated = isRotated; + OriginalSize = originalSize; + Offset = offset; + OriginNormalized = originNormalized; Bounds = new Rectangle(x, y, width, height); Size = new Size(width, height); TopUV = Bounds.Top / (float)texture.Height; @@ -210,6 +257,9 @@ public class Texture2DRegion Height = height; Bounds = new Rectangle(x, y, width, height); Size = new Size(width, height); + OriginalSize = Size; + Offset = Vector2.Zero; + OriginNormalized = null; TopUV = Bounds.Top / 1.0f; RightUV = Bounds.Right / 1.0f; BottomUV = Bounds.Bottom / 1.0f; diff --git a/tests/MonoGame.Extended.Content.Pipeline.Tests/MonoGame.Extended.Content.Pipeline.Tests.csproj b/tests/MonoGame.Extended.Content.Pipeline.Tests/MonoGame.Extended.Content.Pipeline.Tests.csproj index b70b1f62..a0d88934 100644 --- a/tests/MonoGame.Extended.Content.Pipeline.Tests/MonoGame.Extended.Content.Pipeline.Tests.csproj +++ b/tests/MonoGame.Extended.Content.Pipeline.Tests/MonoGame.Extended.Content.Pipeline.Tests.csproj @@ -12,6 +12,7 @@ + diff --git a/tests/MonoGame.Extended.Content.Pipeline.Tests/TestData/test-atlas.json b/tests/MonoGame.Extended.Content.Pipeline.Tests/TestData/test-atlas.json new file mode 100644 index 00000000..cf13b67e --- /dev/null +++ b/tests/MonoGame.Extended.Content.Pipeline.Tests/TestData/test-atlas.json @@ -0,0 +1,120 @@ +{ + "textures": [ + { + "filename": "test-atlas-texture.png", + "format": "RGBA8888", + "size": {"w":917,"h":462}, + "frames": { + "0001.png":{ + "frame": {"x":164,"y":1,"w":158,"h":316}, + "size": {"w":187,"h":324}, + "offset": {"x":15,"y":3}, + "pivot": {"x":0.5,"y":1} + }, + "0002.png":{ + "frame": {"x":164,"y":1,"w":158,"h":316}, + "size": {"w":187,"h":324}, + "offset": {"x":15,"y":3}, + "pivot": {"x":0.5,"y":1} + }, + "0003.png":{ + "frame": {"x":622,"y":155,"w":168,"h":303}, + "size": {"w":187,"h":324}, + "offset": {"x":0,"y":8}, + "pivot": {"x":0.5,"y":1} + }, + "0004.png":{ + "frame": {"x":622,"y":155,"w":168,"h":303}, + "size": {"w":187,"h":324}, + "offset": {"x":0,"y":8}, + "pivot": {"x":0.5,"y":1} + }, + "0005.png":{ + "frame": {"x":609,"y":1,"w":307,"h":152}, + "rotated": 90, + "size": {"w":187,"h":324}, + "offset": {"x":26,"y":2}, + "pivot": {"x":0.5,"y":1} + }, + "0006.png":{ + "frame": {"x":609,"y":1,"w":307,"h":152}, + "rotated": 90, + "size": {"w":187,"h":324}, + "offset": {"x":26,"y":2}, + "pivot": {"x":0.5,"y":1} + }, + "0007.png":{ + "frame": {"x":314,"y":319,"w":306,"h":141}, + "rotated": 90, + "size": {"w":187,"h":324}, + "offset": {"x":42,"y":1}, + "pivot": {"x":0.5,"y":1} + }, + "0008.png":{ + "frame": {"x":314,"y":319,"w":306,"h":141}, + "rotated": 90, + "size": {"w":187,"h":324}, + "offset": {"x":42,"y":1}, + "pivot": {"x":0.5,"y":1} + }, + "0009.png":{ + "frame": {"x":1,"y":322,"w":311,"h":139}, + "rotated": 90, + "size": {"w":187,"h":324}, + "offset": {"x":33,"y":3}, + "pivot": {"x":0.5,"y":1} + }, + "0010.png":{ + "frame": {"x":1,"y":322,"w":311,"h":139}, + "rotated": 90, + "size": {"w":187,"h":324}, + "offset": {"x":33,"y":3}, + "pivot": {"x":0.5,"y":1} + }, + "0011.png":{ + "frame": {"x":472,"y":1,"w":135,"h":311}, + "size": {"w":187,"h":324}, + "offset": {"x":29,"y":8}, + "pivot": {"x":0.5,"y":1} + }, + "0012.png":{ + "frame": {"x":472,"y":1,"w":135,"h":311}, + "size": {"w":187,"h":324}, + "offset": {"x":29,"y":8}, + "pivot": {"x":0.5,"y":1} + }, + "0013.png":{ + "frame": {"x":324,"y":1,"w":146,"h":314}, + "size": {"w":187,"h":324}, + "offset": {"x":32,"y":2}, + "pivot": {"x":0.5,"y":1} + }, + "0014.png":{ + "frame": {"x":324,"y":1,"w":146,"h":314}, + "size": {"w":187,"h":324}, + "offset": {"x":32,"y":2}, + "pivot": {"x":0.5,"y":1} + }, + "0015.png":{ + "frame": {"x":1,"y":1,"w":161,"h":319}, + "size": {"w":187,"h":324}, + "offset": {"x":22,"y":1}, + "pivot": {"x":0.5,"y":1} + }, + "0016.png":{ + "frame": {"x":1,"y":1,"w":161,"h":319}, + "size": {"w":187,"h":324}, + "offset": {"x":22,"y":1}, + "pivot": {"x":0.5,"y":1} + } + } + } + ], + + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "dataformat": "monogame-extended", + "version": "1.2", + "smartupdate": "$TexturePacker:SmartUpdate:682bde45425e0dc70518ec1f7428e5ba:7979479a5ba52a9c36728fb50f2150eb:a2acd4b7aba6ca32e8c17414ae3e522b$" + } +} diff --git a/tests/MonoGame.Extended.Content.Pipeline.Tests/TexturePackerJsonImporterProcessorTests.cs b/tests/MonoGame.Extended.Content.Pipeline.Tests/TexturePackerJsonImporterProcessorTests.cs index c73b4754..7675d4ec 100644 --- a/tests/MonoGame.Extended.Content.Pipeline.Tests/TexturePackerJsonImporterProcessorTests.cs +++ b/tests/MonoGame.Extended.Content.Pipeline.Tests/TexturePackerJsonImporterProcessorTests.cs @@ -15,6 +15,26 @@ namespace MonoGame.Extended.Content.Pipeline.Tests var data = importer.Import(filePath, Substitute.For()); Assert.NotNull(data); + + // Check meta.image contains image name + Assert.Equal("test-tileset.png", data.Meta.Image); + + // Check regions count and textures + Assert.Equal(9, data.Regions.Count); + Assert.Null(data.Textures); // only used by new MonoGame.Extended JSON format + + // Check first region + var firstRegion = data.Regions[0]; + Assert.Equal("1.png", firstRegion.FileName); + Assert.Equal(2, firstRegion.Frame.X); + Assert.Equal(2, firstRegion.Frame.Y); + Assert.Equal(32, firstRegion.Frame.Width); + Assert.Equal(32, firstRegion.Frame.Height); + Assert.Equal(0.5, firstRegion.PivotPoint.X); + Assert.Equal(0.5, firstRegion.PivotPoint.Y); + Assert.False(firstRegion.Rotated); + Assert.Equal(32, firstRegion.SourceSize.Width); + Assert.Equal(32, firstRegion.SourceSize.Height); } [Fact] @@ -28,5 +48,48 @@ namespace MonoGame.Extended.Content.Pipeline.Tests Assert.NotNull(output); } + + [Fact] + public void TexturePackerJsonImporter_Import_NewFormat_Test() + { + var filePath = PathExtensions.GetApplicationFullPath(@"TestData/test-atlas.json"); + var importer = new TexturePackerJsonImporter(); + var data = importer.Import(filePath, Substitute.For()); + + // Regions must be null (only used by old generic json format) + Assert.Null(data.Regions); + + // Textures contains 1 texture + Assert.NotNull(data.Textures); + Assert.Single(data.Textures); + + var texture = data.Textures[0]; + Assert.Equal("test-atlas-texture.png", texture.FileName); + + // The first texture contains frames + Assert.NotNull(texture.Frames); + Assert.Contains("0001.png", texture.Frames.Keys); + + // Check properties of the first frame ("0001.png") + var frame = texture.Frames["0001.png"]; + Assert.Equal(164, frame.Frame.X); + Assert.Equal(1, frame.Frame.Y); + Assert.Equal(158, frame.Frame.Width); + Assert.Equal(316, frame.Frame.Height); + + Assert.NotNull(frame.Size); + Assert.Equal(187, frame.Size.Width); + Assert.Equal(324, frame.Size.Height); + + Assert.NotNull(frame.Offset); + Assert.Equal(15, frame.Offset.X); + Assert.Equal(3, frame.Offset.Y); + + Assert.Equal(0, frame.Rotated); + + Assert.NotNull(frame.Pivot); + Assert.Equal(0.5, frame.Pivot.X); + Assert.Equal(1.0, frame.Pivot.Y); + } } }