mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-26 14:00:26 +00:00
Update Studio previews
- Merge the eight additional commits from PR #1254. - Preserve closure guards while refreshing object and hidden 3D views. - Stop hidden crossfade timers and verify frame retention.
This commit is contained in:
commit
584e8758a7
8 files changed
+659
-12
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,103 @@
|
|||||||
|
/*
|
||||||
|
* 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 javax.swing.Timer;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the last frame of what the explorer showed before on screen while the next thing loads, then fades it out over
|
||||||
|
* the first frame of the new one, so switching never shows an empty view in between.
|
||||||
|
*/
|
||||||
|
final class FrameCrossfade {
|
||||||
|
private static final long FADE_NANOS = TimeUnit.MILLISECONDS.toNanos(70L);
|
||||||
|
private static final int REPAINT_INTERVAL_MILLIS = 16;
|
||||||
|
|
||||||
|
private final Timer repaintTimer;
|
||||||
|
private Phase phase = Phase.NONE;
|
||||||
|
private NoiseRenderCoordinator.Result outgoing;
|
||||||
|
private long fadeStartedNanos;
|
||||||
|
|
||||||
|
FrameCrossfade(Runnable repaint) {
|
||||||
|
this.repaintTimer = new Timer(REPAINT_INTERVAL_MILLIS, event -> {
|
||||||
|
outgoing();
|
||||||
|
repaint.run();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps showing the frame until the next one arrives. Without a frame, as when the switch before hasn't drawn yet,
|
||||||
|
* the frame already held stays.
|
||||||
|
*/
|
||||||
|
void hold(NoiseRenderCoordinator.Result frame) {
|
||||||
|
if (frame == null && outgoing != null) {
|
||||||
|
enter(Phase.HOLDING, outgoing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
enter(frame == null ? Phase.NONE : Phase.HOLDING, frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The new frame is in, so the held one starts fading out over it.
|
||||||
|
*/
|
||||||
|
void reveal() {
|
||||||
|
if (phase != Phase.HOLDING) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fadeStartedNanos = System.nanoTime();
|
||||||
|
phase = Phase.FADING;
|
||||||
|
repaintTimer.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
enter(Phase.NONE, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the frame to draw over the current one, or null when there is none
|
||||||
|
*/
|
||||||
|
NoiseRenderCoordinator.Result outgoing() {
|
||||||
|
if (phase == Phase.FADING && fadedShare() >= 1F) {
|
||||||
|
clear();
|
||||||
|
}
|
||||||
|
return outgoing;
|
||||||
|
}
|
||||||
|
|
||||||
|
float outgoingOpacity() {
|
||||||
|
return phase == Phase.FADING ? 1F - fadedShare() : 1F;
|
||||||
|
}
|
||||||
|
|
||||||
|
private float fadedShare() {
|
||||||
|
return Math.min(1F, (System.nanoTime() - fadeStartedNanos) / (float) FADE_NANOS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void enter(Phase nextPhase, NoiseRenderCoordinator.Result frame) {
|
||||||
|
phase = nextPhase;
|
||||||
|
outgoing = frame;
|
||||||
|
if (nextPhase != Phase.FADING) {
|
||||||
|
repaintTimer.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Phase {
|
||||||
|
NONE,
|
||||||
|
HOLDING,
|
||||||
|
FADING
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ enum MapView {
|
|||||||
Group.BIOME_STREAMS, TileType.BIOME_LAND, KeyEvent.VK_6),
|
Group.BIOME_STREAMS, TileType.BIOME_LAND, KeyEvent.VK_6),
|
||||||
SEA_BIOMES("Sea biomes", "The sea biome each spot would get, also where the land is",
|
SEA_BIOMES("Sea biomes", "The sea biome each spot would get, also where the land is",
|
||||||
Group.BIOME_STREAMS, TileType.BIOME_SEA, KeyEvent.VK_7),
|
Group.BIOME_STREAMS, TileType.BIOME_SEA, KeyEvent.VK_7),
|
||||||
CAVE_BIOMES("Cave biomes", "The cave biome under each spot", Group.BIOME_STREAMS, TileType.CAVE_LAND, KeyEvent.VK_8),
|
CAVE_BIOMES("Cave land","The cave biome under each spot", Group.BIOME_STREAMS, TileType.CAVE_LAND, KeyEvent.VK_8),
|
||||||
PALETTES("Palettes", "The blocks each biome's palette layers place, with their textures",
|
PALETTES("Palettes", "The blocks each biome's palette layers place, with their textures",
|
||||||
Group.BLOCKS, null, KeyEvent.VK_P),
|
Group.BLOCKS, null, KeyEvent.VK_P),
|
||||||
OBJECTS("Objects", "Every object in the pack in 3D, with the blocks it is made of",
|
OBJECTS("Objects", "Every object in the pack in 3D, with the blocks it is made of",
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
/*
|
||||||
|
* 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 and a little above, each box's south and top faces with their 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. A top face shows half as tall as the box is deep, and boxes further back sit higher, as
|
||||||
|
* in a three-quarter view, each face lit as Minecraft lights its side. 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 double TOP_FORESHORTENING = 0.5D;
|
||||||
|
private static final CubeFace FRONT = CubeFace.SOUTH;
|
||||||
|
private static final CubeFace TOP = CubeFace.UP;
|
||||||
|
private static final int ALPHA_MASK = 0xFF000000;
|
||||||
|
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 view, or null when the model has no front or top faces with a texture
|
||||||
|
*/
|
||||||
|
static ModelFrontView draw(JsonArray elements, Map<String, String> textures, TextureImages images) throws IOException {
|
||||||
|
List<ViewedFace> faces = viewedFacesNearestLast(elements, textures);
|
||||||
|
if (faces.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Map<String, BufferedImage> loaded = new HashMap<>();
|
||||||
|
for (ViewedFace 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 view = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
|
||||||
|
int tintIndex = BlockTextures.NO_TINT_INDEX;
|
||||||
|
for (ViewedFace face : faces) {
|
||||||
|
BufferedImage texture = loaded.get(face.texture());
|
||||||
|
if (texture != null) {
|
||||||
|
paint(face, texture, view);
|
||||||
|
tintIndex = tintIndex == BlockTextures.NO_TINT_INDEX ? face.tintIndex() : tintIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasVisiblePixel(view) ? new ModelFrontView(view, 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.modelName())) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boxes further south are nearer the viewer, and of two at the same depth the higher one hides the lower.
|
||||||
|
private static List<ViewedFace> viewedFacesNearestLast(JsonArray elements, Map<String, String> textures) {
|
||||||
|
List<ViewedFace> faces = new ArrayList<>();
|
||||||
|
for (JsonElement element : elements) {
|
||||||
|
JsonObject box = element.getAsJsonObject();
|
||||||
|
JsonObject boxFaces = box.getAsJsonObject("faces");
|
||||||
|
if (boxFaces == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
addFace(faces, box, boxFaces, TOP, textures);
|
||||||
|
addFace(faces, box, boxFaces, FRONT, textures);
|
||||||
|
}
|
||||||
|
faces.sort(Comparator.comparingDouble(ViewedFace::depth).thenComparingDouble(ViewedFace::height));
|
||||||
|
return faces;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addFace(List<ViewedFace> faces, JsonObject box, JsonObject boxFaces, CubeFace side, Map<String, String> textures) {
|
||||||
|
if (!boxFaces.has(side.modelName())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
JsonObject face = boxFaces.getAsJsonObject(side.modelName());
|
||||||
|
String texture = BlockTextures.dereference(face.get("texture").getAsString(), textures);
|
||||||
|
if (texture != null) {
|
||||||
|
faces.add(side == TOP ? ViewedFace.top(box, face, texture) : ViewedFace.front(box, face, texture));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each pixel samples the texture at its centre, along the face's UVs, which run backwards on mirrored faces.
|
||||||
|
private static void paint(ViewedFace face, BufferedImage texture, BufferedImage view) {
|
||||||
|
int size = view.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) {
|
||||||
|
view.setRGB(x, y, argb & ALPHA_MASK | BlockPreviewColors.shade(argb, face.side().shade()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 view) {
|
||||||
|
for (int y = 0; y < view.getHeight(); y++) {
|
||||||
|
for (int x = 0; x < view.getWidth(); x++) {
|
||||||
|
if (view.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 face as it shows in the view, in model units with y running down from the top of the view. The texture's v
|
||||||
|
* runs from the face's top edge in the view to its bottom edge.
|
||||||
|
*/
|
||||||
|
private record ViewedFace(CubeFace side, String texture, int tintIndex, double left, double right, double top, double bottom,
|
||||||
|
double depth, double height, double u1, double v1, double u2, double v2) {
|
||||||
|
static ViewedFace front(JsonObject box, JsonObject face, String texture) {
|
||||||
|
Box bounds = Box.of(box);
|
||||||
|
double top = bounds.viewY(bounds.toY(), bounds.toZ());
|
||||||
|
double bottom = bounds.viewY(bounds.fromY(), bounds.toZ());
|
||||||
|
double[] uv = face.has("uv")
|
||||||
|
? uvOf(face.getAsJsonArray("uv"))
|
||||||
|
: new double[]{bounds.fromX(), MODEL_UNITS - bounds.toY(), bounds.toX(), MODEL_UNITS - bounds.fromY()};
|
||||||
|
return new ViewedFace(FRONT, texture, BlockTextures.tintIndexOf(face), bounds.fromX(), bounds.toX(), top, bottom,
|
||||||
|
bounds.toZ(), bounds.toY(), uv[0], uv[1], uv[2], uv[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The top's back edge is its north side, which the texture's v starts at.
|
||||||
|
static ViewedFace top(JsonObject box, JsonObject face, String texture) {
|
||||||
|
Box bounds = Box.of(box);
|
||||||
|
double top = bounds.viewY(bounds.toY(), bounds.fromZ());
|
||||||
|
double bottom = bounds.viewY(bounds.toY(), bounds.toZ());
|
||||||
|
double[] uv = face.has("uv")
|
||||||
|
? uvOf(face.getAsJsonArray("uv"))
|
||||||
|
: new double[]{bounds.fromX(), bounds.fromZ(), bounds.toX(), bounds.toZ()};
|
||||||
|
return new ViewedFace(TOP, texture, BlockTextures.tintIndexOf(face), bounds.fromX(), bounds.toX(), top, bottom,
|
||||||
|
bounds.toZ(), bounds.toY(), 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()};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Box(double fromX, double fromY, double fromZ, double toX, double toY, double toZ) {
|
||||||
|
static Box of(JsonObject box) {
|
||||||
|
JsonArray from = box.getAsJsonArray("from");
|
||||||
|
JsonArray to = box.getAsJsonArray("to");
|
||||||
|
return new Box(from.get(X).getAsDouble(), from.get(Y).getAsDouble(), from.get(Z).getAsDouble(),
|
||||||
|
to.get(X).getAsDouble(), to.get(Y).getAsDouble(), to.get(Z).getAsDouble());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The front edge of the block stays put; everything further back rises with its distance from it.
|
||||||
|
double viewY(double modelY, double modelZ) {
|
||||||
|
return MODEL_UNITS - modelY - (MODEL_UNITS - modelZ) * TOP_FORESHORTENING;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,8 @@ import java.awt.Component;
|
|||||||
import java.awt.Dimension;
|
import java.awt.Dimension;
|
||||||
import java.awt.EventQueue;
|
import java.awt.EventQueue;
|
||||||
import java.awt.Graphics;
|
import java.awt.Graphics;
|
||||||
|
import java.awt.AlphaComposite;
|
||||||
|
import java.awt.Composite;
|
||||||
import java.awt.Graphics2D;
|
import java.awt.Graphics2D;
|
||||||
import java.awt.Point;
|
import java.awt.Point;
|
||||||
import java.awt.RenderingHints;
|
import java.awt.RenderingHints;
|
||||||
@@ -94,6 +96,7 @@ public final class NoiseExplorerGUI extends JPanel {
|
|||||||
private static final int REFINEMENT_DELAY_MILLIS = 180;
|
private static final int REFINEMENT_DELAY_MILLIS = 180;
|
||||||
private static final long DEFAULT_SEED = 12345L;
|
private static final long DEFAULT_SEED = 12345L;
|
||||||
private static final double TEXTURE_PIXELS_MAX_BLOCKS_PER_PIXEL = 0.25D;
|
private static final double TEXTURE_PIXELS_MAX_BLOCKS_PER_PIXEL = 0.25D;
|
||||||
|
private static final int FULL_DETAIL_SAMPLE_STEP = 1;
|
||||||
private static final double PALETTE_START_BLOCKS_PER_PIXEL = 1D / 16D;
|
private static final double PALETTE_START_BLOCKS_PER_PIXEL = 1D / 16D;
|
||||||
private static final long COARSE_SAMPLE_BUDGET = 4_096L;
|
private static final long COARSE_SAMPLE_BUDGET = 4_096L;
|
||||||
private static final double REFINEMENT_TARGET_MILLIS = 250D;
|
private static final double REFINEMENT_TARGET_MILLIS = 250D;
|
||||||
@@ -137,6 +140,7 @@ public final class NoiseExplorerGUI extends JPanel {
|
|||||||
private final NoiseRenderCoordinator renderCoordinator;
|
private final NoiseRenderCoordinator renderCoordinator;
|
||||||
private final Timer refinementTimer;
|
private final Timer refinementTimer;
|
||||||
private final Timer resizeTimer;
|
private final Timer resizeTimer;
|
||||||
|
private final FrameCrossfade crossfade;
|
||||||
private final AtomicLong sourceLoadRevision = new AtomicLong();
|
private final AtomicLong sourceLoadRevision = new AtomicLong();
|
||||||
private final AtomicLong catalogLoadRevision = new AtomicLong();
|
private final AtomicLong catalogLoadRevision = new AtomicLong();
|
||||||
private final Runnable hotloadHook = this::handleHotload;
|
private final Runnable hotloadHook = this::handleHotload;
|
||||||
@@ -171,6 +175,7 @@ public final class NoiseExplorerGUI extends JPanel {
|
|||||||
private PaletteLayerPreview palettePreview;
|
private PaletteLayerPreview palettePreview;
|
||||||
private int paletteDepth;
|
private int paletteDepth;
|
||||||
private NoiseRenderCoordinator.Result renderResult;
|
private NoiseRenderCoordinator.Result renderResult;
|
||||||
|
private NoiseRenderCoordinator.Result lastTimedFrame;
|
||||||
private NoiseRenderCoordinator.Request interactiveFrame;
|
private NoiseRenderCoordinator.Request interactiveFrame;
|
||||||
private boolean interactiveFramePending;
|
private boolean interactiveFramePending;
|
||||||
private Point pointer;
|
private Point pointer;
|
||||||
@@ -220,6 +225,7 @@ public final class NoiseExplorerGUI extends JPanel {
|
|||||||
this.refinementTimer.setRepeats(false);
|
this.refinementTimer.setRepeats(false);
|
||||||
this.resizeTimer = new Timer(RESIZE_SETTLE_MILLIS, event -> requestPreview());
|
this.resizeTimer = new Timer(RESIZE_SETTLE_MILLIS, event -> requestPreview());
|
||||||
this.resizeTimer.setRepeats(false);
|
this.resizeTimer.setRepeats(false);
|
||||||
|
this.crossfade = new FrameCrossfade(this::repaint);
|
||||||
this.fixedSources = buildFixedSources();
|
this.fixedSources = buildFixedSources();
|
||||||
setBackground(StudioTheme.EDITOR_BACKGROUND);
|
setBackground(StudioTheme.EDITOR_BACKGROUND);
|
||||||
setFocusable(true);
|
setFocusable(true);
|
||||||
@@ -762,6 +768,7 @@ public final class NoiseExplorerGUI extends JPanel {
|
|||||||
selectedSource = null;
|
selectedSource = null;
|
||||||
clearSampler();
|
clearSampler();
|
||||||
renderResult = null;
|
renderResult = null;
|
||||||
|
crossfade.clear();
|
||||||
sourceLoading = false;
|
sourceLoading = false;
|
||||||
rendering = false;
|
rendering = false;
|
||||||
errorMessage = "Pack source removed during hotload: " + removedSource.text();
|
errorMessage = "Pack source removed during hotload: " + removedSource.text();
|
||||||
@@ -854,6 +861,7 @@ public final class NoiseExplorerGUI extends JPanel {
|
|||||||
viewRevision++;
|
viewRevision++;
|
||||||
renderCoordinator.cancel(viewRevision);
|
renderCoordinator.cancel(viewRevision);
|
||||||
sampler = null;
|
sampler = null;
|
||||||
|
crossfade.hold(renderResult);
|
||||||
renderResult = null;
|
renderResult = null;
|
||||||
sourceLoading = true;
|
sourceLoading = true;
|
||||||
rendering = false;
|
rendering = false;
|
||||||
@@ -899,6 +907,7 @@ public final class NoiseExplorerGUI extends JPanel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clearSampler();
|
clearSampler();
|
||||||
|
crossfade.clear();
|
||||||
sourceLoading = false;
|
sourceLoading = false;
|
||||||
rendering = false;
|
rendering = false;
|
||||||
errorMessage = readableError(error);
|
errorMessage = readableError(error);
|
||||||
@@ -929,12 +938,17 @@ public final class NoiseExplorerGUI extends JPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private NoiseColorScale activeColorScale() {
|
private NoiseColorScale activeColorScale() {
|
||||||
|
return activeColorScale(FULL_DETAIL_SAMPLE_STEP);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A coarse frame's sample stands for a whole square of pixels, so texture pixels would show magnified there.
|
||||||
|
private NoiseColorScale activeColorScale(int sampleStep) {
|
||||||
PaletteLayerPreview preview = palettePreview;
|
PaletteLayerPreview preview = palettePreview;
|
||||||
if (preview == null) {
|
if (preview == null) {
|
||||||
BlockRange blockRange = samplerBlockRange;
|
BlockRange blockRange = samplerBlockRange;
|
||||||
return blockRange == null ? palette : new BlockHeightColorScale(palette, blockRange.lowest(), blockRange.highest());
|
return blockRange == null ? palette : new BlockHeightColorScale(palette, blockRange.lowest(), blockRange.highest());
|
||||||
}
|
}
|
||||||
boolean showTexturePixels = viewport.blocksPerPixel() <= TEXTURE_PIXELS_MAX_BLOCKS_PER_PIXEL;
|
boolean showTexturePixels = viewport.blocksPerPixel() * sampleStep <= TEXTURE_PIXELS_MAX_BLOCKS_PER_PIXEL;
|
||||||
return new PaletteLayerScale(paletteInspector.selectedView(), preview, palette, showTexturePixels);
|
return new PaletteLayerScale(paletteInspector.selectedView(), preview, palette, showTexturePixels);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1044,9 +1058,10 @@ public final class NoiseExplorerGUI extends JPanel {
|
|||||||
return interactiveFrame != null && interactiveFrame.revision() == viewRevision;
|
return interactiveFrame != null && interactiveFrame.revision() == viewRevision;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The last frame's speed predicts this one, so cheap views draw sharp straight away instead of starting blocky.
|
// The last frame's speed predicts this one, so cheap views draw sharp straight away instead of starting blocky. It
|
||||||
|
// outlives a switch to another source, which samples about as fast.
|
||||||
private long interactiveSampleBudget() {
|
private long interactiveSampleBudget() {
|
||||||
NoiseRenderCoordinator.Result lastResult = renderResult;
|
NoiseRenderCoordinator.Result lastResult = lastTimedFrame;
|
||||||
if (lastResult == null) {
|
if (lastResult == null) {
|
||||||
return COARSE_SAMPLE_BUDGET;
|
return COARSE_SAMPLE_BUDGET;
|
||||||
}
|
}
|
||||||
@@ -1104,7 +1119,7 @@ public final class NoiseExplorerGUI extends JPanel {
|
|||||||
viewRevision,
|
viewRevision,
|
||||||
sampler,
|
sampler,
|
||||||
viewport,
|
viewport,
|
||||||
activeColorScale(),
|
activeColorScale(sampleStep),
|
||||||
getWidth(),
|
getWidth(),
|
||||||
renderHeight(),
|
renderHeight(),
|
||||||
sampleStep
|
sampleStep
|
||||||
@@ -1222,6 +1237,7 @@ public final class NoiseExplorerGUI extends JPanel {
|
|||||||
}
|
}
|
||||||
refinementTimer.stop();
|
refinementTimer.stop();
|
||||||
resizeTimer.stop();
|
resizeTimer.stop();
|
||||||
|
crossfade.clear();
|
||||||
MapHost.unregisterHotloadHook(hotloadHook);
|
MapHost.unregisterHotloadHook(hotloadHook);
|
||||||
sourceLoadRevision.incrementAndGet();
|
sourceLoadRevision.incrementAndGet();
|
||||||
catalogLoadRevision.incrementAndGet();
|
catalogLoadRevision.incrementAndGet();
|
||||||
@@ -1246,6 +1262,7 @@ public final class NoiseExplorerGUI extends JPanel {
|
|||||||
if (renderResult != null) {
|
if (renderResult != null) {
|
||||||
drawLatestFrame(canvas, renderResult, getWidth(), renderHeight);
|
drawLatestFrame(canvas, renderResult, getWidth(), renderHeight);
|
||||||
}
|
}
|
||||||
|
drawOutgoingFrame(canvas, getWidth(), renderHeight);
|
||||||
drawCrosshair(canvas, getWidth(), renderHeight);
|
drawCrosshair(canvas, getWidth(), renderHeight);
|
||||||
drawLegend(canvas, getWidth());
|
drawLegend(canvas, getWidth());
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1267,6 +1284,17 @@ public final class NoiseExplorerGUI extends JPanel {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void drawOutgoingFrame(Graphics2D canvas, int width, int renderHeight) {
|
||||||
|
NoiseRenderCoordinator.Result outgoing = crossfade.outgoing();
|
||||||
|
if (outgoing == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Composite opaque = canvas.getComposite();
|
||||||
|
canvas.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, crossfade.outgoingOpacity()));
|
||||||
|
drawLatestFrame(canvas, outgoing, width, renderHeight);
|
||||||
|
canvas.setComposite(opaque);
|
||||||
|
}
|
||||||
|
|
||||||
// Moves and scales the last finished frame to the current view, so the map follows the mouse before the next frame lands.
|
// Moves and scales the last finished frame to the current view, so the map follows the mouse before the next frame lands.
|
||||||
private void drawLatestFrame(Graphics2D canvas, NoiseRenderCoordinator.Result frame, int width, int renderHeight) {
|
private void drawLatestFrame(Graphics2D canvas, NoiseRenderCoordinator.Result frame, int width, int renderHeight) {
|
||||||
NoiseRenderCoordinator.Request request = frame.request();
|
NoiseRenderCoordinator.Request request = frame.request();
|
||||||
@@ -1618,6 +1646,8 @@ public final class NoiseExplorerGUI extends JPanel {
|
|||||||
rendering = false;
|
rendering = false;
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
renderResult = result;
|
renderResult = result;
|
||||||
|
lastTimedFrame = result;
|
||||||
|
crossfade.reveal();
|
||||||
repaint();
|
repaint();
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
if (finishInteractiveFrame(result.request())) {
|
if (finishInteractiveFrame(result.request())) {
|
||||||
|
|||||||
@@ -406,8 +406,9 @@ final class OrbitViewport extends JComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Frames go straight into the window when the scene can be drawn that way, Swing has nothing to paint over the
|
* Frames go straight into the window when the view is on screen, the scene can be drawn that way, Swing has nothing
|
||||||
* whole view, and the screen's pixels are the view's own. The window is shown before the frame is drawn into it.
|
* to paint over the whole view, and the screen's pixels are the view's own. The window is shown before the frame is
|
||||||
|
* drawn into it. Showing it maps it even while its card is hidden, where it would cover the card that is open.
|
||||||
*
|
*
|
||||||
* @return the window, or nothing to draw a picture for Swing instead
|
* @return the window, or nothing to draw a picture for Swing instead
|
||||||
*/
|
*/
|
||||||
@@ -422,8 +423,8 @@ final class OrbitViewport extends JComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean drawsOnScreen() {
|
private boolean drawsOnScreen() {
|
||||||
return canvas != null && canvas.window() != 0L && scene != null && !scene.isEmpty() && scene.canRenderOnScreen()
|
return canvas != null && isShowing() && canvas.window() != 0L && scene != null && !scene.isEmpty()
|
||||||
&& !isLoadingShown() && hasUnscaledPixels();
|
&& scene.canRenderOnScreen() && !isLoadingShown() && hasUnscaledPixels();
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean hasUnscaledPixels() {
|
private boolean hasUnscaledPixels() {
|
||||||
@@ -434,20 +435,21 @@ final class OrbitViewport extends JComponent {
|
|||||||
/**
|
/**
|
||||||
* Until the card is opened, nothing knows whether frames can go into the window; the first picture opens it. A
|
* Until the card is opened, nothing knows whether frames can go into the window; the first picture opens it. A
|
||||||
* frame that should have gone into the window but couldn't isn't tried again right away. A frame for a scene
|
* frame that should have gone into the window but couldn't isn't tried again right away. A frame for a scene
|
||||||
* replaced meanwhile is never shown.
|
* replaced meanwhile is never shown. The frame counts as rendering until it is on its way to the screen, so a
|
||||||
|
* frame asked for while it is taken, like when it ends a load, can't be drawn over it first.
|
||||||
*/
|
*/
|
||||||
private synchronized void finishFrame(OrbitScene frameScene, OrbitCamera frameCamera, ScreenTarget screen, OrbitPicture drawn,
|
private synchronized void finishFrame(OrbitScene frameScene, OrbitCamera frameCamera, ScreenTarget screen, OrbitPicture drawn,
|
||||||
boolean missedScreen, long requestedAtNanos) {
|
boolean missedScreen, long requestedAtNanos) {
|
||||||
if (closed) {
|
if (closed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
rendering = false;
|
|
||||||
measureFrameTime(System.nanoTime() - requestedAtNanos);
|
measureFrameTime(System.nanoTime() - requestedAtNanos);
|
||||||
if (frameScene == scene) {
|
if (frameScene == scene) {
|
||||||
takePicture(drawn, frameCamera);
|
takePicture(drawn, frameCamera);
|
||||||
paintPicture(drawn);
|
paintPicture(drawn);
|
||||||
showInWindow(screen, drawn);
|
showInWindow(screen, drawn);
|
||||||
}
|
}
|
||||||
|
rendering = false;
|
||||||
boolean canGoOnScreen = !missedScreen && canvas != null && !canvas.isVisible() && drawsOnScreen();
|
boolean canGoOnScreen = !missedScreen && canvas != null && !canvas.isVisible() && drawsOnScreen();
|
||||||
if (renderAgain || drawn.catchingUp() || canGoOnScreen) {
|
if (renderAgain || drawn.catchingUp() || canGoOnScreen) {
|
||||||
renderAgain = false;
|
renderAgain = false;
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package art.arcane.iris.studio.map;
|
||||||
|
|
||||||
|
import org.junit.After;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import javax.swing.SwingUtilities;
|
||||||
|
import javax.swing.Timer;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertNull;
|
||||||
|
import static org.junit.Assert.assertSame;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
public final class FrameCrossfadeTest {
|
||||||
|
private final FrameCrossfade crossfade = new FrameCrossfade(() -> {
|
||||||
|
});
|
||||||
|
|
||||||
|
@After
|
||||||
|
public void stopRepaintTimer() throws Exception {
|
||||||
|
SwingUtilities.invokeAndWait(crossfade::clear);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void heldFrameStaysFullyVisibleUntilTheNextFrameArrives() {
|
||||||
|
NoiseRenderCoordinator.Result frame = frame();
|
||||||
|
|
||||||
|
crossfade.hold(frame);
|
||||||
|
|
||||||
|
assertSame(frame, crossfade.outgoing());
|
||||||
|
assertEquals(1F, crossfade.outgoingOpacity(), 0F);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void heldFrameFadesOnceTheNextFrameArrives() throws Exception {
|
||||||
|
SwingUtilities.invokeAndWait(() -> {
|
||||||
|
crossfade.hold(frame());
|
||||||
|
crossfade.reveal();
|
||||||
|
});
|
||||||
|
Thread.sleep(20L);
|
||||||
|
|
||||||
|
SwingUtilities.invokeAndWait(() -> assertTrue(crossfade.outgoing() == null || crossfade.outgoingOpacity() < 1F));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void fadingStopsAndReleasesTheFrameWithoutPainting() throws Exception {
|
||||||
|
Timer timer = (Timer) field("repaintTimer");
|
||||||
|
SwingUtilities.invokeAndWait(() -> {
|
||||||
|
crossfade.hold(frame());
|
||||||
|
crossfade.reveal();
|
||||||
|
assertTrue(timer.isRunning());
|
||||||
|
});
|
||||||
|
|
||||||
|
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2L);
|
||||||
|
while (timer.isRunning() && System.nanoTime() < deadline) {
|
||||||
|
Thread.sleep(10L);
|
||||||
|
}
|
||||||
|
SwingUtilities.invokeAndWait(() -> { });
|
||||||
|
|
||||||
|
assertFalse("Hidden views must stop scheduling fade repaints", timer.isRunning());
|
||||||
|
assertNull(field("outgoing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void holdingDuringFadeStopsRepaintsUntilAnotherFrameArrives() throws Exception {
|
||||||
|
Timer timer = (Timer) field("repaintTimer");
|
||||||
|
NoiseRenderCoordinator.Result frame = frame();
|
||||||
|
SwingUtilities.invokeAndWait(() -> {
|
||||||
|
crossfade.hold(frame);
|
||||||
|
crossfade.reveal();
|
||||||
|
crossfade.hold(null);
|
||||||
|
|
||||||
|
assertFalse("A held frame must not keep the fade timer running", timer.isRunning());
|
||||||
|
assertSame(frame, crossfade.outgoing());
|
||||||
|
assertEquals(1F, crossfade.outgoingOpacity(), 0F);
|
||||||
|
|
||||||
|
crossfade.reveal();
|
||||||
|
assertTrue(timer.isRunning());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void switchingAgainBeforeAnythingDrewKeepsTheFrameHeld() {
|
||||||
|
NoiseRenderCoordinator.Result frame = frame();
|
||||||
|
crossfade.hold(frame);
|
||||||
|
|
||||||
|
crossfade.hold(null);
|
||||||
|
|
||||||
|
assertSame(frame, crossfade.outgoing());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void frameWithoutAHeldFrameHasNothingToFade() {
|
||||||
|
crossfade.reveal();
|
||||||
|
|
||||||
|
assertNull(crossfade.outgoing());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void clearDropsTheHeldFrame() {
|
||||||
|
crossfade.hold(frame());
|
||||||
|
|
||||||
|
crossfade.clear();
|
||||||
|
|
||||||
|
assertNull(crossfade.outgoing());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NoiseRenderCoordinator.Result frame() {
|
||||||
|
return new NoiseRenderCoordinator.Result(null, null, 1D, 1L, 0D, 1D, 0.5D, 0L, 0L, 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object field(String name) throws Exception {
|
||||||
|
Field field = FrameCrossfade.class.getDeclaredField(name);
|
||||||
|
field.setAccessible(true);
|
||||||
|
return field.get(crossfade);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
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(litAsFront(0, 5), front.image().getRGB(6, 7));
|
||||||
|
assertEquals(litAsFront(3, 10), front.image().getRGB(9, 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void topShowsAboveTheFrontOverThePlaneJustUnderIt() throws Exception {
|
||||||
|
ModelFrontView front = draw(SEA_PICKLE);
|
||||||
|
|
||||||
|
assertEquals(texture.getRGB(5, 2), front.image().getRGB(7, 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void crossedPlaneIsMirroredWhereItsUvRunsBackwards() throws Exception {
|
||||||
|
ModelFrontView front = draw(SEA_PICKLE);
|
||||||
|
|
||||||
|
assertEquals(litAsFront(1, 1), front.image().getRGB(8, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 int litAsFront(int textureX, int textureY) {
|
||||||
|
return 0xFF000000 | BlockPreviewColors.shade(texture.getRGB(textureX, textureY), CubeFace.SOUTH.shade());
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user