Draw small decorations from the front in palette previews

- Sea pickles, candles, lanterns, turtle eggs and potted plants show their shape instead of their whole unfolded texture
- Flat decorations like pressure plates, rails and lily pads keep their top view

Model used: Claude Opus 5.5
This commit is contained in:
piexel committed 2026-10-25 19:22:02 +01:00
1 parent 48bfa7d15b
commit 5204d94ebe
3 files changed
+345 -1

No files matched your search

@@ -51,6 +51,7 @@ final class BlockTextures {
private final Map<String, Optional<ResolvedTexture>> resolvedTextures = new ConcurrentHashMap<>(); private final Map<String, Optional<ResolvedTexture>> resolvedTextures = new ConcurrentHashMap<>();
private final Map<String, Optional<BlockModel>> resolvedModels = new ConcurrentHashMap<>(); private final Map<String, Optional<BlockModel>> resolvedModels = new ConcurrentHashMap<>();
private final Map<String, Optional<BlockTexture>> faceTextures = new ConcurrentHashMap<>(); private final Map<String, Optional<BlockTexture>> faceTextures = new ConcurrentHashMap<>();
private final Map<String, Optional<BlockTexture>> icons = new ConcurrentHashMap<>();
private final BlockModelBuilder modelBuilder = new BlockModelBuilder(this); private final BlockModelBuilder modelBuilder = new BlockModelBuilder(this);
private BlockTextures(ZipFile clientJar, MinecraftBiomeColors biomeColors) { private BlockTextures(ZipFile clientJar, MinecraftBiomeColors biomeColors) {
@@ -89,10 +90,40 @@ final class BlockTextures {
/** /**
* Like {@link #topTexture}, but plants keep their sprite, so single decorations on the ground stay recognisable. * Like {@link #topTexture}, but plants keep their sprite, so single decorations on the ground stay recognisable.
* Small shapes standing on their spot, like sea pickles and candles, are drawn from the front for the same reason.
* Two block tall plants give their bottom half, the part standing on the ground. * Two block tall plants give their bottom half, the part standing on the ground.
*/ */
Optional<BlockTexture> iconTexture(NativeBlockState block) { Optional<BlockTexture> iconTexture(NativeBlockState block) {
return resolve(withHalf(block, BlockHalf.LOWER).orElse(block)).map(ResolvedTexture::texture); NativeBlockState standing = withHalf(block, BlockHalf.LOWER).orElse(block);
return icons.computeIfAbsent(standing.key(),
state -> frontViewIcon(state).or(() -> resolve(standing).map(ResolvedTexture::texture)));
}
// A small shape keeps all its parts side by side on one texture, so its top face's whole texture shows them all.
private Optional<BlockTexture> frontViewIcon(String blockState) {
try {
BlockStateId state = BlockStateId.parse(blockState);
JsonObject variant = variantOf(state);
if (variant == null || !variant.has("model")) {
return Optional.empty();
}
ModelDefinition model = readModel(variant.get("model").getAsString());
if (model.elements() == null || model.sideFace("up") == null || ModelFrontView.fillsMostOfItsSpotFromAbove(model.elements())) {
return Optional.empty();
}
ModelFrontView front = ModelFrontView.draw(model.elements(), model.textures(),
texture -> readImage(assetPath(texture, "textures", ".png")));
if (front == null) {
return Optional.empty();
}
return Optional.of(BlockTexture.fromImage(
front.image(),
MinecraftBlockTints.fixedTint(state.name(), state.properties(), front.tintIndex()),
MinecraftBlockTints.biomeTint(state.name(), front.tintIndex()).orElse(null)
));
} catch (IOException | RuntimeException error) {
return Optional.empty();
}
} }
/** /**
@@ -0,0 +1,221 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.studio.map;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* A block model drawn flat as seen from the south, each box's south face with its own part of the texture. Small
* shapes like sea pickles and candles keep all their parts on one texture, which only makes sense mapped onto the
* boxes. Tilted boxes are drawn untilted, which is close for the thin crossed planes such shapes use.
*/
final class ModelFrontView {
private static final double MODEL_UNITS = 16D;
private static final String FRONT = "south";
private static final String TOP = "up";
private static final double MOSTLY_COVERED_SHARE = 0.5D;
private static final int X = 0;
private static final int Y = 1;
private static final int Z = 2;
/**
* Reads a texture's image by its sprite name.
*/
@FunctionalInterface
interface TextureImages {
BufferedImage read(String texture) throws IOException;
}
private final BufferedImage image;
private final int tintIndex;
private ModelFrontView(BufferedImage image, int tintIndex) {
this.image = image;
this.tintIndex = tintIndex;
}
/**
* @return the front view, or null when the model has no front faces with a texture
*/
static ModelFrontView draw(JsonArray elements, Map<String, String> textures, TextureImages images) throws IOException {
List<FrontFace> faces = frontFacesNearestLast(elements, textures);
if (faces.isEmpty()) {
return null;
}
Map<String, BufferedImage> loaded = new HashMap<>();
for (FrontFace face : faces) {
BufferedImage texture = images.read(face.texture());
if (texture != null) {
loaded.put(face.texture(), texture);
}
}
if (loaded.isEmpty()) {
return null;
}
int size = loaded.values().iterator().next().getWidth();
BufferedImage front = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
int tintIndex = BlockTextures.NO_TINT_INDEX;
for (FrontFace face : faces) {
BufferedImage texture = loaded.get(face.texture());
if (texture != null) {
paint(face, texture, front);
tintIndex = tintIndex == BlockTextures.NO_TINT_INDEX ? face.tintIndex() : tintIndex;
}
}
return hasVisiblePixel(front) ? new ModelFrontView(front, tintIndex) : null;
}
/**
* Whether the model's top faces cover most of its spot, like a slab, a carpet or a pressure plate does, rather than
* being a small thing standing on it.
*/
static boolean fillsMostOfItsSpotFromAbove(JsonArray elements) {
int size = (int) MODEL_UNITS;
return topCoveredPixels(elements, size) >= size * size * MOSTLY_COVERED_SHARE;
}
private static int topCoveredPixels(JsonArray elements, int size) {
boolean[] covered = new boolean[size * size];
for (JsonElement element : elements) {
JsonObject box = element.getAsJsonObject();
JsonObject faces = box.getAsJsonObject("faces");
if (faces == null || !faces.has(TOP)) {
continue;
}
JsonArray from = box.getAsJsonArray("from");
JsonArray to = box.getAsJsonArray("to");
fill(covered, size, pixel(from.get(X), size), pixel(to.get(X), size), pixel(from.get(Z), size), pixel(to.get(Z), size));
}
int count = 0;
for (boolean pixelCovered : covered) {
count += pixelCovered ? 1 : 0;
}
return count;
}
BufferedImage image() {
return image;
}
int tintIndex() {
return tintIndex;
}
// Faces further south are nearer the viewer, so they are painted over the ones behind them.
private static List<FrontFace> frontFacesNearestLast(JsonArray elements, Map<String, String> textures) {
List<FrontFace> faces = new ArrayList<>();
for (JsonElement element : elements) {
JsonObject box = element.getAsJsonObject();
JsonObject boxFaces = box.getAsJsonObject("faces");
if (boxFaces == null || !boxFaces.has(FRONT)) {
continue;
}
JsonObject face = boxFaces.getAsJsonObject(FRONT);
String texture = BlockTextures.dereference(face.get("texture").getAsString(), textures);
if (texture != null) {
faces.add(FrontFace.of(box, face, texture));
}
}
faces.sort(Comparator.comparingDouble(FrontFace::depth));
return faces;
}
// Each pixel samples the texture at its centre, along the face's UVs, which run backwards on mirrored faces.
private static void paint(FrontFace face, BufferedImage texture, BufferedImage front) {
int size = front.getWidth();
double scale = size / MODEL_UNITS;
double left = face.left() * scale;
double right = face.right() * scale;
double top = face.top() * scale;
double bottom = face.bottom() * scale;
int textureSize = texture.getWidth();
for (int y = (int) Math.round(top); y < Math.round(bottom); y++) {
double alongV = (y + 0.5D - top) / (bottom - top);
int textureY = texel(face.v1() + alongV * (face.v2() - face.v1()), textureSize);
for (int x = (int) Math.round(left); x < Math.round(right); x++) {
double alongU = (x + 0.5D - left) / (right - left);
int textureX = texel(face.u1() + alongU * (face.u2() - face.u1()), textureSize);
int argb = texture.getRGB(textureX, textureY);
if (argb >>> 24 != 0 && x >= 0 && y >= 0 && x < size && y < size) {
front.setRGB(x, y, argb);
}
}
}
}
// Animated textures stack their frames, so the first frame is as tall as the texture is wide.
private static int texel(double modelUnits, int textureSize) {
return Math.clamp((int) Math.floor(modelUnits / MODEL_UNITS * textureSize), 0, textureSize - 1);
}
private static boolean hasVisiblePixel(BufferedImage front) {
for (int y = 0; y < front.getHeight(); y++) {
for (int x = 0; x < front.getWidth(); x++) {
if (front.getRGB(x, y) >>> 24 != 0) {
return true;
}
}
}
return false;
}
private static int pixel(JsonElement modelUnits, int size) {
return (int) Math.round(modelUnits.getAsDouble() / MODEL_UNITS * size);
}
private static void fill(boolean[] covered, int size, int fromX, int toX, int fromZ, int toZ) {
for (int z = Math.max(0, fromZ); z < Math.min(size, toZ); z++) {
for (int x = Math.max(0, fromX); x < Math.min(size, toX); x++) {
covered[z * size + x] = true;
}
}
}
/**
* A south face in model units, with the image's y running down from the model's top.
*/
private record FrontFace(String texture, int tintIndex, double left, double right, double top, double bottom, double depth,
double u1, double v1, double u2, double v2) {
static FrontFace of(JsonObject box, JsonObject face, String texture) {
JsonArray from = box.getAsJsonArray("from");
JsonArray to = box.getAsJsonArray("to");
double left = from.get(X).getAsDouble();
double right = to.get(X).getAsDouble();
double top = MODEL_UNITS - to.get(Y).getAsDouble();
double bottom = MODEL_UNITS - from.get(Y).getAsDouble();
double[] uv = face.has("uv") ? uvOf(face.getAsJsonArray("uv")) : new double[]{left, top, right, bottom};
return new FrontFace(texture, BlockTextures.tintIndexOf(face), left, right, top, bottom, to.get(Z).getAsDouble(),
uv[0], uv[1], uv[2], uv[3]);
}
private static double[] uvOf(JsonArray uv) {
return new double[]{uv.get(0).getAsDouble(), uv.get(1).getAsDouble(), uv.get(2).getAsDouble(), uv.get(3).getAsDouble()};
}
}
}
@@ -0,0 +1,92 @@
package art.arcane.iris.studio.map;
import com.google.gson.JsonArray;
import com.google.gson.JsonParser;
import org.junit.Test;
import java.awt.image.BufferedImage;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
public final class ModelFrontViewTest {
private static final String SEA_PICKLE = """
[
{ "from": [6, 0, 6], "to": [10, 6, 10], "faces": {
"up": { "uv": [4, 1, 8, 5], "texture": "#all" },
"south": { "uv": [0, 5, 4, 11], "texture": "#all" } } },
{ "from": [6, 5.95, 6], "to": [10, 5.95, 10], "faces": {
"up": { "uv": [8, 1, 12, 5], "texture": "#all" } } },
{ "from": [7.5, 5.2, 8], "to": [8.5, 8.7, 8],
"rotation": { "origin": [8, 8, 8], "axis": "y", "angle": 45, "rescale": true },
"faces": { "south": { "uv": [3, 0, 1, 5], "texture": "#all" } } }
]
""";
private static final String PRESSURE_PLATE = """
[
{ "from": [1, 0, 1], "to": [15, 1, 15], "faces": {
"up": { "uv": [1, 1, 15, 15], "texture": "#all" },
"south": { "uv": [1, 15, 15, 16], "texture": "#all" } } }
]
""";
private static final Map<String, String> TEXTURES = Map.of("all", "block/test");
private static final int SIZE = 16;
private final BufferedImage texture = everyTexelItsOwnColour();
@Test
public void bodyShowsItsOwnPartOfTheTexture() throws Exception {
ModelFrontView front = draw(SEA_PICKLE);
assertEquals(texture.getRGB(0, 5), front.image().getRGB(6, 10));
assertEquals(texture.getRGB(3, 10), front.image().getRGB(9, 15));
}
@Test
public void crossedPlaneIsMirroredWhereItsUvRunsBackwards() throws Exception {
ModelFrontView front = draw(SEA_PICKLE);
assertEquals(texture.getRGB(1, 3), front.image().getRGB(8, 9));
}
@Test
public void spotsNoBoxCoversStayClear() throws Exception {
ModelFrontView front = draw(SEA_PICKLE);
assertEquals(0, front.image().getRGB(2, 12) >>> 24);
assertEquals(0, front.image().getRGB(7, 2) >>> 24);
}
@Test
public void smallShapeLeavesMostOfItsSpotOpen() {
assertFalse(ModelFrontView.fillsMostOfItsSpotFromAbove(elements(SEA_PICKLE)));
}
@Test
public void flatShapeFillsMostOfItsSpot() {
assertTrue(ModelFrontView.fillsMostOfItsSpotFromAbove(elements(PRESSURE_PLATE)));
}
private ModelFrontView draw(String elements) throws Exception {
ModelFrontView front = ModelFrontView.draw(elements(elements), TEXTURES, name -> texture);
assertNotNull(front);
return front;
}
private static JsonArray elements(String json) {
return JsonParser.parseString(json).getAsJsonArray();
}
private static BufferedImage everyTexelItsOwnColour() {
BufferedImage image = new BufferedImage(SIZE, SIZE, BufferedImage.TYPE_INT_ARGB);
for (int y = 0; y < SIZE; y++) {
for (int x = 0; x < SIZE; x++) {
image.setRGB(x, y, 0xFF000000 | x << 16 | y << 8 | 0x40);
}
}
return image;
}
}