Add base for 3D skin heads!!!
All checks were successful
Deploy App / docker (ubuntu-latest, 2.44.0, 17, 3.8.5) (push) Successful in 1m35s

This commit is contained in:
Braydon 2024-04-11 03:04:50 -04:00
parent 16a42d1af3
commit 2f7b9f6b10
7 changed files with 391 additions and 71 deletions

14
pom.xml

@ -116,6 +116,20 @@
<scope>compile</scope> <scope>compile</scope>
</dependency> </dependency>
<!-- 3D Rendering 🧑🏼🔫 -->
<dependency>
<groupId>java3d</groupId>
<artifactId>j3d-core</artifactId>
<version>1.3.1</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>java3d</groupId>
<artifactId>j3d-core-utils</artifactId>
<version>1.3.1</version>
<scope>compile</scope>
</dependency>
<!-- SwaggerUI --> <!-- SwaggerUI -->
<dependency> <dependency>
<groupId>org.springdoc</groupId> <groupId>org.springdoc</groupId>

@ -24,68 +24,30 @@
package me.braydon.mc.common; package me.braydon.mc.common;
import lombok.NonNull; import lombok.NonNull;
import lombok.SneakyThrows;
import lombok.experimental.UtilityClass; import lombok.experimental.UtilityClass;
import me.braydon.mc.model.Skin;
import javax.imageio.ImageIO; import java.awt.*;
import java.awt.geom.AffineTransform; import java.awt.geom.AffineTransform;
import java.awt.image.AffineTransformOp;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.net.URL;
/** /**
* @author Braydon * @author Braydon
*/ */
@UtilityClass @UtilityClass
public final class ImageUtils { public final class ImageUtils {
public static final int SKIN_TEXTURE_SIZE = 64; // The skin of a skin texture
/** /**
* Get the texture of a part of a skin. * Scale the given image to the provided size.
* *
* @param skin the skin to get the part texture from * @param image the image to scale
* @param part the part of the skin to get * @param size the size to scale the image to
* @param size the size to scale the texture to * @return the scaled image
* @return the texture of the skin part
*/ */
@SneakyThrows @NonNull
public static byte[] getSkinPart(@NonNull Skin skin, @NonNull Skin.Part part, int size) { public static BufferedImage resize(@NonNull BufferedImage image, double size) {
return getSkinPartTexture(skin, part.getX(), part.getY(), part.getWidth(), part.getHeight(), size); BufferedImage scaled = new BufferedImage((int) (image.getWidth() * size), (int) (image.getHeight() * size), BufferedImage.TYPE_INT_ARGB);
} Graphics2D graphics = scaled.createGraphics();
graphics.drawImage(image, AffineTransform.getScaleInstance(size, size), null);
/** graphics.dispose();
* Get the texture of a specific part of a skin. return scaled;
*
* @param skin the skin to get the part from
* @param x the x position of the part
* @param y the y position of the part
* @param width the width of the part
* @param height the height of the part
* @param size the size to scale the part to
* @return the texture of the skin part
*/
@SneakyThrows
public static byte[] getSkinPartTexture(@NonNull Skin skin, int x, int y, int width, int height, int size) {
BufferedImage skinImage = ImageIO.read(new URL(skin.getUrl())); // The skin texture
// Create a new BufferedImage for the part of the skin texture
BufferedImage headTexture = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
// Crop just the part we want based on our x, y, width, and height
headTexture.getGraphics().drawImage(skinImage, 0, 0, width, height, x, y, x + width, y + height, null);
// Scale the skin part texture
double scale = (double) size / width;
AffineTransform transform = AffineTransform.getScaleInstance(scale, scale);
headTexture = new AffineTransformOp(transform, AffineTransformOp.TYPE_NEAREST_NEIGHBOR).filter(headTexture, null);
// Convert BufferedImage to byte array
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
ImageIO.write(headTexture, "png", outputStream);
outputStream.flush();
return outputStream.toByteArray();
}
} }
} }

@ -0,0 +1,93 @@
/*
* MIT License
*
* Copyright (c) 2024 Braydon (Rainnny).
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package me.braydon.mc.common.renderer;
import lombok.NonNull;
import lombok.SneakyThrows;
import me.braydon.mc.common.ImageUtils;
import me.braydon.mc.model.Skin;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.net.URL;
/**
* A renderer for a {@link Skin.Part}.
*
* @author Braydon
* @param <T> the type of part to render
*/
public abstract class SkinPartRenderer<T extends Skin.IPart> {
/**
* Invoke this render to render the
* given skin part for the provided skin.
*
* @param skin the skin to render the part for
* @param part the part to render
* @param overlays whether to render overlays
* @param size the size to scale the skin part to
* @return the rendered skin part
*/
@NonNull public abstract BufferedImage render(@NonNull Skin skin, @NonNull T part, boolean overlays, int size);
/**
* Get the texture of a part of a skin.
*
* @param skin the skin to get the part texture from
* @param part the part of the skin to get
* @param size the size to scale the texture to
* @return the texture of the skin part
*/
@SneakyThrows
protected BufferedImage getSkinPart(@NonNull Skin skin, @NonNull Skin.Part part, double size) {
return getSkinPartTexture(skin, part.getCoordinates().getX(), part.getCoordinates().getY(), part.getWidth(), part.getHeight(), size);
}
/**
* Get the texture of a specific part of a skin.
*
* @param skin the skin to get the part from
* @param x the x position of the part
* @param y the y position of the part
* @param width the width of the part
* @param height the height of the part
* @param size the size to scale the part to
* @return the texture of the skin part
*/
@SneakyThrows
private BufferedImage getSkinPartTexture(@NonNull Skin skin, int x, int y, int width, int height, double size) {
BufferedImage skinImage = ImageIO.read(new URL(skin.getUrl())); // The skin texture
// Create a new BufferedImage for the part of the skin texture
BufferedImage headTexture = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
// Crop just the part we want based on our x, y, width, and height
headTexture.getGraphics().drawImage(skinImage, 0, 0, width, height, x, y, x + width, y + height, null);
// Scale the skin part texture
headTexture = ImageUtils.resize(headTexture, size);
return headTexture;
}
}

@ -0,0 +1,54 @@
/*
* MIT License
*
* Copyright (c) 2024 Braydon (Rainnny).
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package me.braydon.mc.common.renderer.impl;
import lombok.NonNull;
import me.braydon.mc.common.renderer.SkinPartRenderer;
import me.braydon.mc.model.Skin;
import java.awt.image.BufferedImage;
/**
* A basic 2D renderer for a {@link Skin.Part}.
*
* @author Braydon
*/
public final class BasicSkinPartRenderer extends SkinPartRenderer<Skin.Part> {
public static final BasicSkinPartRenderer INSTANCE = new BasicSkinPartRenderer();
/**
* Invoke this render to render the
* given skin part for the provided skin.
*
* @param skin the skin to render the part for
* @param part the part to render
* @param overlays whether to render overlays
* @param size the size to scale the skin part to
* @return the rendered skin part
*/
@Override @NonNull
public BufferedImage render(@NonNull Skin skin, @NonNull Skin.Part part, boolean overlays, int size) {
return getSkinPart(skin, part, size / 8D);
}
}

@ -0,0 +1,101 @@
/*
* MIT License
*
* Copyright (c) 2024 Braydon (Rainnny).
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package me.braydon.mc.common.renderer.impl;
import lombok.NonNull;
import me.braydon.mc.common.renderer.SkinPartRenderer;
import me.braydon.mc.model.Skin;
import java.awt.*;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
/**
* A isometric 3D renderer for a {@link Skin.Part}.
*
* @author Braydon
*/
public final class IsometricSkinPartRenderer extends SkinPartRenderer<Skin.IsometricPart> {
public static final IsometricSkinPartRenderer INSTANCE = new IsometricSkinPartRenderer();
private static final double SKEW_A = 26D / 45D; // 0.57777777
private static final double SKEW_B = SKEW_A * 2D; // 1.15555555
private static final AffineTransform HEAD_TOP_TRANSFORM = new AffineTransform(1D, -SKEW_A, 1, SKEW_A, 0, 0);
private static final AffineTransform FACE_TRANSFORM = new AffineTransform(1D, -SKEW_A, 0D, SKEW_B, 0d, SKEW_A);
private static final AffineTransform HEAD_LEFT_TRANSFORM = new AffineTransform(1D, SKEW_A, 0D, SKEW_B, 0D, 0D);
/**
* Invoke this render to render the
* given skin part for the provided skin.
*
* @param skin the skin to render the part for
* @param part the part to render
* @param overlays whether to render overlays
* @param size the size to scale the skin part to
* @return the rendered skin part
*/
@Override @NonNull
public BufferedImage render(@NonNull Skin skin, @NonNull Skin.IsometricPart part, boolean overlays, int size) {
double scale = (size / 8D) / 2.5;
double zOffset = scale * 3.5D;
double xOffset = scale * 2D;
BufferedImage texture = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
Graphics2D graphics = texture.createGraphics();
BufferedImage headTop = getSkinPart(skin, Skin.Part.HEAD_TOP, scale);
BufferedImage face = getSkinPart(skin, Skin.Part.FACE, scale);
BufferedImage headLeft = getSkinPart(skin, Skin.Part.HEAD_LEFT, scale);
// Draw the top of the left
drawPart(graphics, headTop, HEAD_TOP_TRANSFORM, -0.5 - zOffset, xOffset + zOffset, headTop.getWidth(), headTop.getHeight() + 2);
// Draw the face of the head
double x = xOffset + 8 * scale;
drawPart(graphics, face, FACE_TRANSFORM, x, x + zOffset - 0.5, face.getWidth(), face.getHeight());
// Draw the left side of the head
drawPart(graphics, headLeft, HEAD_LEFT_TRANSFORM, xOffset + 1, zOffset - 0.5, headLeft.getWidth(), headLeft.getHeight());
return texture;
}
/**
* Draw a part onto the texture.
*
* @param graphics the graphics to draw to
* @param partImage the part image to draw
* @param transform the transform to apply
* @param x the x position to draw at
* @param y the y position to draw at
* @param width the part image width
* @param height the part image height
*/
private void drawPart(@NonNull Graphics2D graphics, @NonNull BufferedImage partImage, @NonNull AffineTransform transform,
double x, double y, int width, int height) {
graphics.setTransform(transform);
graphics.drawImage(partImage, (int) x, (int) y, width, height, null);
}
}

@ -26,11 +26,11 @@ package me.braydon.mc.model;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import lombok.*; import lombok.*;
import me.braydon.mc.common.ImageUtils;
import me.braydon.mc.config.AppConfig; import me.braydon.mc.config.AppConfig;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.function.Consumer;
/** /**
* A skin for a {@link Player}. * A skin for a {@link Player}.
@ -64,8 +64,14 @@ public final class Skin {
*/ */
@NonNull @NonNull
public Skin populatePartUrls(@NonNull String playerUuid) { public Skin populatePartUrls(@NonNull String playerUuid) {
for (Part part : Part.values()) { Consumer<IPart> addPart = part -> {
partUrls.put(part.name(), AppConfig.INSTANCE.getServerPublicUrl() + "/player/" + part.name().toLowerCase() + "/" + playerUuid + ".png"); partUrls.put(part.name(), AppConfig.INSTANCE.getServerPublicUrl() + "/player/" + part.name().toLowerCase() + "/" + playerUuid + ".png");
};
for (Part part : Part.values()) {
addPart.accept(part);
}
for (IsometricPart part : IsometricPart.values()) {
addPart.accept(part);
} }
return this; return this;
} }
@ -93,28 +99,91 @@ public final class Skin {
* Possible models for a skin. * Possible models for a skin.
*/ */
public enum Model { public enum Model {
SLIM, DEFAULT DEFAULT, SLIM
}
/**
* Represents a part of a skin.
*/
public interface IPart {
/**
* Get the name of this part.
*
* @return the part name
*/
@NonNull String name();
} }
/** /**
* The part of a skin. * The part of a skin.
*/ */
@AllArgsConstructor @Getter @ToString @AllArgsConstructor @RequiredArgsConstructor @Getter @ToString
public enum Part { public enum Part implements IPart {
HEAD_TOP(8, 0, ImageUtils.SKIN_TEXTURE_SIZE / 8, ImageUtils.SKIN_TEXTURE_SIZE / 8), // Head
FACE(8, 8, ImageUtils.SKIN_TEXTURE_SIZE / 8, ImageUtils.SKIN_TEXTURE_SIZE / 8), HEAD_TOP(new Coordinates(8, 0), 8, 8),
HEAD_LEFT(0, 8, ImageUtils.SKIN_TEXTURE_SIZE / 8, ImageUtils.SKIN_TEXTURE_SIZE / 8), FACE(new Coordinates(8, 8), 8, 8),
HEAD_RIGHT(16, 8, ImageUtils.SKIN_TEXTURE_SIZE / 8, ImageUtils.SKIN_TEXTURE_SIZE / 8), HEAD_LEFT(new Coordinates(0, 8), 8, 8),
HEAD_BOTTOM(8, 8, ImageUtils.SKIN_TEXTURE_SIZE / 8, ImageUtils.SKIN_TEXTURE_SIZE / 8); HEAD_RIGHT(new Coordinates(16, 8), 8, 8),
HEAD_BOTTOM(new Coordinates(16, 0), 8, 8),
HEAD_BACK(new Coordinates(24, 8), 8, 8);
/** /**
* The coordinates of this part. * The coordinates of this part.
*/ */
private final int x, y; @NonNull private final Coordinates coordinates;
/**
* The legacy coordinates of this part.
* <p>
* This is for older skin textures
* that use different positions.
* </p>
*/
private LegacyCoordinates legacyCoordinates;
/** /**
* The size of this part. * The size of this part.
*/ */
private final int width, height; private final int width, height;
/**
* Coordinates of a part of a skin.
*/
@AllArgsConstructor @Getter @ToString
public static class Coordinates {
/**
* The X coordinate.
*/
private final int x;
/**
* The Y coordinate.
*/
private final int y;
}
/**
* Legacy coordinates of a part of a skin.
*/
@Getter @ToString
public static class LegacyCoordinates extends Coordinates {
/**
* Whether the part at these coordinates is flipped.
*/
private final boolean flipped;
public LegacyCoordinates(int x, int y) {
this(x, y, false);
}
public LegacyCoordinates(int x, int y, boolean flipped) {
super(x, y);
this.flipped = flipped;
}
}
}
public enum IsometricPart implements IPart {
HEAD
} }
} }

@ -32,6 +32,8 @@ import lombok.NonNull;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import lombok.extern.log4j.Log4j2; import lombok.extern.log4j.Log4j2;
import me.braydon.mc.common.*; import me.braydon.mc.common.*;
import me.braydon.mc.common.renderer.impl.BasicSkinPartRenderer;
import me.braydon.mc.common.renderer.impl.IsometricSkinPartRenderer;
import me.braydon.mc.common.web.JsonWebException; import me.braydon.mc.common.web.JsonWebException;
import me.braydon.mc.common.web.JsonWebRequest; import me.braydon.mc.common.web.JsonWebRequest;
import me.braydon.mc.exception.impl.BadRequestException; import me.braydon.mc.exception.impl.BadRequestException;
@ -60,6 +62,9 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.InputStream; import java.io.InputStream;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
import java.net.URL; import java.net.URL;
@ -163,8 +168,26 @@ public final class MojangService {
* @throws BadRequestException if the extension is invalid * @throws BadRequestException if the extension is invalid
* @throws MojangRateLimitException if the Mojang API rate limit is reached * @throws MojangRateLimitException if the Mojang API rate limit is reached
*/ */
@SneakyThrows
public byte[] getSkinPartTexture(@NonNull String partName, @NonNull String query, @NonNull String extension, String sizeString) public byte[] getSkinPartTexture(@NonNull String partName, @NonNull String query, @NonNull String extension, String sizeString)
throws BadRequestException, MojangRateLimitException { throws BadRequestException, MojangRateLimitException {
partName = partName.toUpperCase(); // The part name to get
// Get the part from the given name
Skin.IPart part = EnumUtils.getEnumConstant(Skin.Part.class, partName); // The skin part to get
if (part == null) { // The given part is invalid, try a isometric part
part = EnumUtils.getEnumConstant(Skin.IsometricPart.class, partName);;
}
if (part == null) { // Default to the face
part = Skin.Part.FACE;
}
// Ensure the extension is valid
if (extension.isBlank()) {
throw new BadRequestException("Invalid extension");
}
// Get the size of the part
Integer size = null; Integer size = null;
if (sizeString != null) { // Attempt to parse the size if (sizeString != null) { // Attempt to parse the size
try { try {
@ -173,13 +196,6 @@ public final class MojangService {
// Safely ignore, invalid number provided // Safely ignore, invalid number provided
} }
} }
Skin.Part part = EnumUtils.getEnumConstant(Skin.Part.class, partName.toUpperCase()); // The skin part to get
if (part == null) { // Default to the head part
part = Skin.Part.FACE;
}
if (extension.isBlank()) { // Invalid extension
throw new BadRequestException("Invalid extension");
}
if (size == null || size <= 0) { // Invalid size if (size == null || size <= 0) { // Invalid size
size = DEFAULT_PART_TEXTURE_SIZE; size = DEFAULT_PART_TEXTURE_SIZE;
} }
@ -201,9 +217,19 @@ public final class MojangService {
if (target == null) { // Fallback to the default skin if (target == null) { // Fallback to the default skin
target = Skin.DEFAULT_STEVE; target = Skin.DEFAULT_STEVE;
} }
byte[] texture = ImageUtils.getSkinPart(target, part, size); BufferedImage texture = part instanceof Skin.IsometricPart isometricPart ?
skinPartTextureCache.save(new CachedSkinPartTexture(id, texture)); // Cache the texture IsometricSkinPartRenderer.INSTANCE.render(target, isometricPart, true, size)
return texture; : BasicSkinPartRenderer.INSTANCE.render(target, (Skin.Part) part, true, size); // Render the skin part
// Convert BufferedImage to byte array
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
ImageIO.write(texture, "png", outputStream);
outputStream.flush();
byte[] bytes = outputStream.toByteArray();
skinPartTextureCache.save(new CachedSkinPartTexture(id, bytes)); // Cache the texture
return bytes;
}
} }
/** /**
@ -268,6 +294,7 @@ public final class MojangService {
uuid, token.getName(), uuid, token.getName(),
skinProperties.getSkin() == null ? Skin.DEFAULT_STEVE : skinProperties.getSkin(), skinProperties.getSkin() == null ? Skin.DEFAULT_STEVE : skinProperties.getSkin(),
skinProperties.getCape(), skinProperties.getCape(),
token.getProperties(),
profileActions.length == 0 ? null : profileActions, profileActions.length == 0 ? null : profileActions,
System.currentTimeMillis() System.currentTimeMillis()
); );