diff --git a/PCK-Studio/PckStudio.csproj b/PCK-Studio/PckStudio.csproj index 014aec0f..c31f9212 100644 --- a/PCK-Studio/PckStudio.csproj +++ b/PCK-Studio/PckStudio.csproj @@ -687,6 +687,8 @@ + + diff --git a/PCK-Studio/Properties/Resources.Designer.cs b/PCK-Studio/Properties/Resources.Designer.cs index 2d31c501..992038ff 100644 --- a/PCK-Studio/Properties/Resources.Designer.cs +++ b/PCK-Studio/Properties/Resources.Designer.cs @@ -232,6 +232,16 @@ namespace PckStudio.Properties { } } + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + public static System.Drawing.Bitmap CUSTOM_SKIN_ICON { + get { + object obj = ResourceManager.GetObject("CUSTOM SKIN ICON", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + /// /// Looks up a localized resource of type System.Drawing.Bitmap. /// @@ -252,6 +262,16 @@ namespace PckStudio.Properties { } } + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + public static System.Drawing.Bitmap empty { + get { + object obj = ResourceManager.GetObject("empty", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + /// /// Looks up a localized resource of type System.Drawing.Bitmap. /// diff --git a/PCK-Studio/Properties/Resources.resx b/PCK-Studio/Properties/Resources.resx index ce81f2a6..dcec626b 100644 --- a/PCK-Studio/Properties/Resources.resx +++ b/PCK-Studio/Properties/Resources.resx @@ -421,4 +421,10 @@ ..\Resources\iconImageList\SLIM SKIN ICON.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + ..\Resources\iconImageList\CUSTOM SKIN ICON.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\iconImageList\empty.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + \ No newline at end of file diff --git a/PCK-Studio/Resources/iconImageList/CUSTOM SKIN ICON.png b/PCK-Studio/Resources/iconImageList/CUSTOM SKIN ICON.png new file mode 100644 index 00000000..5000a8ac Binary files /dev/null and b/PCK-Studio/Resources/iconImageList/CUSTOM SKIN ICON.png differ diff --git a/PCK-Studio/Resources/iconImageList/empty.png b/PCK-Studio/Resources/iconImageList/empty.png new file mode 100644 index 00000000..fdf950d0 Binary files /dev/null and b/PCK-Studio/Resources/iconImageList/empty.png differ diff --git a/PckStudio.Core/Extensions/SkinExtensions.cs b/PckStudio.Core/Extensions/SkinExtensions.cs index 57765bbd..25686c5d 100644 --- a/PckStudio.Core/Extensions/SkinExtensions.cs +++ b/PckStudio.Core/Extensions/SkinExtensions.cs @@ -1,12 +1,17 @@ -using System; +using OMI.Formats.Languages; +using OMI.Formats.Model; +using OMI.Formats.Pck; +using OpenTK; +using PckStudio.Core.Skin; +using System; using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; using System.Drawing.Imaging; using System.Linq; +using System.Numerics; using System.Text; using System.Threading.Tasks; -using OMI.Formats.Languages; -using OMI.Formats.Pck; -using PckStudio.Core.Skin; namespace PckStudio.Core.Extensions { @@ -67,5 +72,340 @@ namespace PckStudio.Core.Extensions capeFile.SetTexture(skin.CapeTexture); return capeFile; } + + // Function to create a paper doll from the skin for use in 2D contexts + public static Image DrawPaperDoll(this Skin.Skin skin, float pixelScale = 10.0f, int xmlVersion = 0, bool bustCrop = false) + { + //pixel scale set to 10 so inflated parts can be seen as properly as possible + + bool isWideSkin = skin.Anim.GetFlag(SkinAnimFlag.MODERN_WIDE_MODEL); + bool isSlimSkin = skin.Anim.GetFlag(SkinAnimFlag.SLIM_MODEL); + bool isModernSkin = isWideSkin || isSlimSkin; + bool isDinnerbone = skin.Anim.GetFlag(SkinAnimFlag.DINNERBONE); + bool isStatueOfLiberty = skin.Anim.GetFlag(SkinAnimFlag.STATUE_OF_LIBERTY); + + float textureScaleX = skin.Texture.Width / 64f; // minecraft skins always have a width of 64 + float textureScaleY = skin.Texture.Height / (isModernSkin ? 64f : 32f); + + // start with a large canvas and crop down later + Image paperDoll = new Bitmap(512, 512); + + using (Graphics gfx = Graphics.FromImage(paperDoll)) + { + gfx.InterpolationMode = InterpolationMode.NearestNeighbor; + gfx.PixelOffsetMode = PixelOffsetMode.HighQuality; + gfx.SmoothingMode = SmoothingMode.None; + + SkinBOX baseHeadBox = new SkinBOX("HEAD", new(-4, -8, -4), new(8), new(0, 0)); + SkinBOX baseTorsoBox = new SkinBOX("BODY", new(-4, 0, -2), new(8, 12, 4), new(16, 16)); + + List baseBoxes = new List(); + List skinBoxes = new List(); + + if (!skin.Anim.GetFlag(SkinAnimFlag.HEAD_DISABLED)) + { + baseBoxes.Add(baseHeadBox); + } + if (!skin.Anim.GetFlag(SkinAnimFlag.HEADWEAR_DISABLED)) + { + baseBoxes.Add(new SkinBOX("HEADWEAR", new(-4, -8, -4), new(8), new(32, 0), scale: 0.5f)); + } + if (!skin.Anim.GetFlag(SkinAnimFlag.TORSO_DISABLED)) + { + baseBoxes.Add(baseTorsoBox); + } + if (!skin.Anim.GetFlag(SkinAnimFlag.JACKET_DISABLED) && isModernSkin) + { + baseBoxes.Add(new SkinBOX("JACKET", new(-4, 0, -2), new(8, 12, 4), new(16, 32), scale: 0.25f)); + } + if (!skin.Anim.GetFlag(SkinAnimFlag.RIGHT_ARM_DISABLED)) + { + baseBoxes.Add(new SkinBOX("ARM0", new(isSlimSkin ? -2 : -3, -2, -2), new(isSlimSkin ? 3 : 4, 12, 4), new(40, 16))); + } + if (!skin.Anim.GetFlag(SkinAnimFlag.RIGHT_SLEEVE_DISABLED) && isModernSkin) + { + baseBoxes.Add(new SkinBOX("SLEEVE0", new(isSlimSkin ? -2 : -3, -2, -2), new(isSlimSkin ? 3 : 4, 12, 4), new(40, 32), scale: 0.25f)); + } + if (!skin.Anim.GetFlag(SkinAnimFlag.LEFT_ARM_DISABLED)) + { + if (!isModernSkin) + { + baseBoxes.Add(new SkinBOX("ARM1", new(-1, -2, -2), new(4, 12, 4), new(40, 16), mirror: true)); + } + else + { + baseBoxes.Add(new SkinBOX("ARM1", new(-1, -2, -2), new(isSlimSkin ? 3 : 4, 12, 4), new(32, 48))); + } + } + if (!skin.Anim.GetFlag(SkinAnimFlag.LEFT_SLEEVE_DISABLED) && isModernSkin) + { + baseBoxes.Add(new SkinBOX("SLEEVE1", new(-1, -2, -2), new(isSlimSkin ? 3 : 4, 12, 4), new(48, 48), scale: 0.25f)); + } + if (!skin.Anim.GetFlag(SkinAnimFlag.RIGHT_LEG_DISABLED)) + { + baseBoxes.Add(new SkinBOX("LEG0", new(-2, 0, -2), new(4, 12, 4), new(0, 16))); + } + if (!skin.Anim.GetFlag(SkinAnimFlag.RIGHT_PANTS_DISABLED) && isModernSkin) + { + baseBoxes.Add(new SkinBOX("PANTS0", new(-2, 0, -2), new(4, 12, 4), new(0, 32), scale: 0.25f)); + } + if (!skin.Anim.GetFlag(SkinAnimFlag.LEFT_LEG_DISABLED)) + { + if (!isModernSkin) + { + baseBoxes.Add(new SkinBOX("LEG1", new(-2, 0, -2), new(4, 12, 4), new(0, 16), mirror: true)); + } + else + { + baseBoxes.Add(new SkinBOX("LEG1", new(-2, 0, -2), new(4, 12, 4), new(16, 48))); + } + } + if (!skin.Anim.GetFlag(SkinAnimFlag.LEFT_PANTS_DISABLED) && isModernSkin) + { + baseBoxes.Add(new SkinBOX("PANTS1", new(-2, 0, -2), new(4, 12, 4), new(0, 48), scale: 0.25f)); + } + + skinBoxes.AddRange(baseBoxes); + skinBoxes.AddRange(skin.Model.AdditionalBoxes); + + skinBoxes = skinBoxes + .OrderByDescending(box => + isStatueOfLiberty && box.Type == "ARM0" + ? -box.Pos.Z + : box.Pos.Z + ) + .OrderBy(box => box.Scale) + .ToList(); + + // Minecraft skins are ordinarily 16 pixels wide and 32 pixels tall + // a padding of 4 has been added for fitting overlay parts + float canvasWidth = 0; + float canvasHeight = 0; + float canvasMinX = float.MaxValue; + float canvasMinY = float.MaxValue; + float canvasMaxX = float.MinValue; + float canvasMaxY = float.MinValue; + + // used to center the render around the torso to best use space + float torsoCenterY = baseTorsoBox.Pos.Y + (baseTorsoBox.Size.Y / 2f); + + float defaultDrawOffsetX = paperDoll.Width / 2f; // draw at center of the canvas + float defaultDrawOffsetY = paperDoll.Height / 2f - torsoCenterY * pixelScale; // draw from center of the torso box + + foreach (SkinBOX box in skinBoxes) + { + try + { + float boxWidth = box.Size.X; + float boxHeight = box.Size.Y; + float boxDepth = box.Size.Z; + + if (boxWidth <= 0 || boxHeight <= 0) // if either of these are 0, skip because drawing is impossible + continue; + + bool isStatueOfLibertyArm = isStatueOfLiberty && (box.Type == "ARM0" || box.Type == "SLEEVE0" || box.Type == "ARMARMOR0"); + + // this math is basically to ensure the face is stretched if the texture is improper + Rectangle faceRect = new Rectangle( + isStatueOfLibertyArm // get back of ARM 0 box if Statue of Liberty + ? (int)((box.UV.X + boxWidth + 2 * boxDepth) * textureScaleX) // depth + width + depth = offset for back of box + : (int)((box.UV.X + boxDepth) * textureScaleX), + (int)((box.UV.Y + boxDepth) * textureScaleY), + (int)(boxWidth * textureScaleX), + (int)(boxHeight * textureScaleY) + ); + + Image boxFace = skin.Texture.GetArea(faceRect); + if (box.Mirror) boxFace.RotateFlip(RotateFlipType.RotateNoneFlipX); + if (isStatueOfLibertyArm) + { + boxFace.RotateFlip(RotateFlipType.RotateNoneFlipY); + } + + float drawOffsetX = defaultDrawOffsetX; // draw at center of the canvas + float drawOffsetY = defaultDrawOffsetY; + + // offset by 4 pixels so feet align with where the head should be for the bust crop. This might not be perfect, but it gets the job done for the 2 "vanilla" upside-down skins + if (isDinnerbone) + drawOffsetY -= 4 * pixelScale; + + float armXOffset = GameConstants.SkinLeftArmTranslation.X * pixelScale; + float armYOffset = GameConstants.SkinLeftArmTranslation.Y * pixelScale; + float legXOffset = GameConstants.SkinLeftLegTranslation.X * pixelScale; + float legYOffset = GameConstants.SkinLeftLegTranslation.Y * pixelScale; + + switch (box.Type) + { + case "HEAD": + case "HEADWEAR": + drawOffsetY += skin.Model.PartOffsets.Find(x => x.Type == "HEAD").Value * pixelScale; + break; + case "BODY": + case "WAIST": + case "JACKET": + case "BELT": + case "BODYARMOR": + drawOffsetY += skin.Model.PartOffsets.Find(x => x.Type == "BODY").Value * pixelScale; + break; + case "ARM0": + case "SLEEVE0": + case "ARMARMOR0": + drawOffsetX -= armXOffset; + drawOffsetY += armYOffset + skin.Model.PartOffsets.Find(x => x.Type == "ARM0").Value * pixelScale; + break; + case "ARM1": + case "SLEEVE1": + case "ARMARMOR1": + drawOffsetX += armXOffset; + drawOffsetY += armYOffset + skin.Model.PartOffsets.Find(x => x.Type == "ARM1").Value * pixelScale; + break; + case "LEG0": + case "PANTS0": + case "LEGGING0": + case "SOCK0": + case "BOOT0": + drawOffsetX -= legXOffset; + drawOffsetY += legYOffset + skin.Model.PartOffsets.Find(x => x.Type == "LEG0").Value * pixelScale; + break; + case "LEG1": + case "PANTS1": + case "LEGGING1": + case "SOCK1": + case "BOOT1": + drawOffsetX += legXOffset; + drawOffsetY += legYOffset + skin.Model.PartOffsets.Find(x => x.Type == "LEG1").Value * pixelScale; + break; + } + + float boxPosY = box.Pos.Y; + + if (isStatueOfLibertyArm) + { + boxPosY = -(box.Pos.Y + box.Size.Y); + } + + float drawX = box.Pos.X * pixelScale; + float drawY = boxPosY * pixelScale; + + float drawWidth = boxWidth * pixelScale; + float drawHeight = boxHeight * pixelScale; + + // handle Box scale + float boxInflate = + // if XMLVersion 3 or a base box; then return box scale + (xmlVersion == 3 || baseBoxes.Contains(box)) ? box.Scale + // if not; then check that it is an overlay box + : (xmlVersion > 0 && xmlVersion < 3 && SkinBOX.OverlayTypes.Contains(box.Type)) + // if so, return hardcoded box inflate value + ? (box.Type == "HEADWEAR" ? 0.5f : 0.25f) + : 0f; // finally, if not caught anywhere else, return no scale + + float drawInflate = boxInflate * pixelScale * 1.5f; // no idea why 1.5 seems to fix this :,) + float halfInflation = drawInflate * 0.5f; + + float finalDrawX = drawOffsetX + drawX - halfInflation; + float finalDrawY = drawOffsetY + drawY - halfInflation; + + float finalDrawWidth = drawWidth + drawInflate; + float finalDrawHeight = drawHeight + drawInflate; + + RectangleF boxRect = new RectangleF(finalDrawX, finalDrawY, finalDrawWidth, finalDrawHeight); + + if (isStatueOfLiberty && box.Type == "ARM0") + { + float pivotX = defaultDrawOffsetX + GameConstants.SkinRightArmPivot.X * pixelScale; + float pivotY = defaultDrawOffsetY + GameConstants.SkinRightArmPivot.Y * pixelScale; + + float angle = -8f * (float)Math.PI / 180f; + + // used to calculate if the bounds of the arm go beyond the current bounds of the image so that the entire skin is shown + PointF[] armBounds = + { + new PointF(finalDrawX, finalDrawY), + new PointF(finalDrawX + finalDrawWidth, finalDrawY), + new PointF(finalDrawX, finalDrawY + finalDrawHeight), + new PointF(finalDrawX + finalDrawWidth, finalDrawY + finalDrawHeight) + }; + + foreach (var bound in armBounds) + { + float finalX = bound.X - pivotX; + float finalY = bound.Y - pivotY; + + float rotateX = pivotX + (finalX * (float)Math.Cos(angle) - finalY * (float)Math.Sin(angle)); + float rotateY = pivotY + (finalX * (float)Math.Sin(angle) + finalY * (float)Math.Cos(angle)); + + canvasMinX = Math.Min(canvasMinX, rotateX); + canvasMinY = Math.Min(canvasMinY, rotateY); + canvasMaxX = Math.Max(canvasMaxX, rotateX); + canvasMaxY = Math.Max(canvasMaxY, rotateY); + } + + var state = gfx.Save(); + + gfx.TranslateTransform(pivotX, pivotY); + gfx.RotateTransform(-8f); + gfx.TranslateTransform(-pivotX, -pivotY); + + gfx.DrawImage(boxFace, boxRect); + + gfx.Restore(state); + } + else + { + canvasMinX = Math.Min(canvasMinX, finalDrawX); + canvasMinY = Math.Min(canvasMinY, finalDrawY); + canvasMaxX = Math.Max(canvasMaxX, finalDrawX + finalDrawWidth); + canvasMaxY = Math.Max(canvasMaxY, finalDrawY + finalDrawHeight); + + gfx.DrawImage(boxFace, boxRect); + } + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + } + } + + canvasWidth = canvasMaxX - canvasMinX; + canvasHeight = canvasMaxY - canvasMinY; + + Console.WriteLine($"{skin.Identifier.Id} {canvasWidth}x{canvasHeight}"); + + Rectangle cropRect = new Rectangle( + (int)Math.Floor(canvasMinX), + (int)Math.Floor(canvasMinY), + (int)Math.Ceiling(canvasMaxX - canvasMinX), + (int)Math.Ceiling(canvasMaxY - canvasMinY) + ); + + if (bustCrop) + { + if(isDinnerbone) + paperDoll.RotateFlip(RotateFlipType.RotateNoneFlipY); + + float headCenterY = baseHeadBox.Pos.Y + (baseHeadBox.Size.Y / 2f); + + float cropX = defaultDrawOffsetX; + float cropY = defaultDrawOffsetY + (headCenterY * pixelScale); + + const int size = 256; + + cropRect = new Rectangle( + (int)Math.Floor(cropX - size / 2f), + (int)Math.Floor(cropY - size / 2f), + size, + size + ); + } + + paperDoll = paperDoll.GetArea(cropRect); + + if (isDinnerbone && !bustCrop) + paperDoll.RotateFlip(RotateFlipType.RotateNoneFlipY); + + return paperDoll; + } + } } } diff --git a/PckStudio.Core/Skin/Skin.cs b/PckStudio.Core/Skin/Skin.cs index 60eb7f7c..0f1236de 100644 --- a/PckStudio.Core/Skin/Skin.cs +++ b/PckStudio.Core/Skin/Skin.cs @@ -1,43 +1,38 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Drawing; -using System.Linq; -using System.Security.Policy; -using System.Text; -using System.Threading.Tasks; namespace PckStudio.Core.Skin { public sealed class Skin { public SkinMetaData MetaData { get; set; } - + public SkinIdentifier Identifier { get; set; } - + public SkinANIM Anim { get; set; } public SkinGameFlags GameFlags { get; set; } public SkinModel Model { get; set; } - + public Image Texture { get; set; } - + public Image CapeTexture { get; set; } public bool HasCape => CapeTexture is not null; - + public Skin(string name, Image texture) { MetaData = new SkinMetaData(name, string.Empty); Texture = texture; Model = new SkinModel(); } - + public Skin(string name, Image texture, Image capeTexture) : this(name, texture) { CapeTexture = capeTexture; - } + } public Skin(string name, SkinANIM anim, SkinGameFlags gameFlags, Image texture, IEnumerable additionalBoxes, IEnumerable partOffsets) : this(name, texture)