diff --git a/core/src/main/java/com/volmit/iris/core/gui/LoadingAnimation.java b/core/src/main/java/com/volmit/iris/core/gui/LoadingAnimation.java
new file mode 100644
index 000000000..6853d1b38
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/LoadingAnimation.java
@@ -0,0 +1,145 @@
+/*
+ * 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 .
+ */
+package com.volmit.iris.core.gui;
+
+import java.awt.AlphaComposite;
+import java.awt.Color;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.geom.Path2D;
+import java.awt.image.BufferedImage;
+
+/**
+ * A little cube of blocks building itself while something loads: the blocks drop in one by one, the cube holds a
+ * moment, fades, and starts over. Loads shorter than {@link #SHOW_AFTER_MILLIS} never show it, so they don't flash.
+ */
+final class LoadingAnimation {
+ static final int SHOW_AFTER_MILLIS = 300;
+ static final int FRAME_MILLIS = 33;
+
+ // Back to front, so each block lands on or beside ones already there.
+ private static final int[][] BUILD_ORDER = {
+ {0, 0, 0}, {1, 0, 0}, {0, 0, 1}, {1, 0, 1},
+ {0, 1, 0}, {1, 1, 0}, {0, 1, 1}, {1, 1, 1}};
+ private static final int STEP_MILLIS = 140;
+ private static final int DROP_MILLIS = 260;
+ private static final int HOLD_MILLIS = 450;
+ private static final int FADE_MILLIS = 320;
+ private static final int CYCLE_MILLIS = STEP_MILLIS * (BUILD_ORDER.length - 1) + DROP_MILLIS + HOLD_MILLIS + FADE_MILLIS;
+ private static final double DROP_HEIGHT_BLOCKS = 1.6D;
+ private static final double ISOMETRIC_WIDTH = Math.cos(Math.PI / 6D);
+ private static final double ISOMETRIC_DEPTH = 0.5D;
+ private static final float SIDE_SHADE = 0.78F;
+ private static final float FAR_SIDE_SHADE = 0.6F;
+
+ private LoadingAnimation() {
+ }
+
+ /**
+ * Whether a load that began at {@code loadingSinceMillis} has taken long enough to show the animation.
+ */
+ static boolean isDue(long loadingSinceMillis) {
+ return System.currentTimeMillis() - loadingSinceMillis >= SHOW_AFTER_MILLIS;
+ }
+
+ /**
+ * Draws the cube centred on a point, {@code blockSize} pixels to a block edge, as it looks
+ * {@code elapsedMillis} after it first showed.
+ */
+ static void paint(Graphics2D graphics, double centerX, double centerY, double blockSize, long elapsedMillis) {
+ long time = Math.floorMod(elapsedMillis, CYCLE_MILLIS);
+ long fadeStart = CYCLE_MILLIS - FADE_MILLIS;
+ if (time < fadeStart) {
+ drawCube(graphics, centerX, centerY, blockSize, time);
+ return;
+ }
+ fadeCube(graphics, centerX, centerY, blockSize, time, 1F - (time - fadeStart) / (float) FADE_MILLIS);
+ }
+
+ // Faded block by block, the blocks behind would show through; the whole cube is drawn first and faded as one.
+ private static void fadeCube(Graphics2D graphics, double centerX, double centerY, double blockSize, long time, float opacity) {
+ int extent = (int) Math.ceil(blockSize * (2D + DROP_HEIGHT_BLOCKS) * 2D);
+ BufferedImage cube = new BufferedImage(extent, extent, BufferedImage.TYPE_INT_ARGB);
+ Graphics2D cubeCanvas = cube.createGraphics();
+ drawCube(cubeCanvas, extent / 2D, extent / 2D, blockSize, time);
+ cubeCanvas.dispose();
+ Graphics2D canvas = (Graphics2D) graphics.create();
+ try {
+ canvas.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, Math.max(0F, opacity)));
+ canvas.drawImage(cube, (int) Math.round(centerX - extent / 2D), (int) Math.round(centerY - extent / 2D), null);
+ } finally {
+ canvas.dispose();
+ }
+ }
+
+ private static void drawCube(Graphics2D graphics, double centerX, double centerY, double blockSize, long time) {
+ Graphics2D canvas = (Graphics2D) graphics.create();
+ try {
+ canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+ for (int index = 0; index < BUILD_ORDER.length; index++) {
+ long sinceDropStart = time - (long) index * STEP_MILLIS;
+ if (sinceDropStart < 0) {
+ break;
+ }
+ double fallen = easeOut(Math.min(1D, sinceDropStart / (double) DROP_MILLIS));
+ int[] block = BUILD_ORDER[index];
+ drawBlock(canvas, centerX, centerY, blockSize, block[0], block[1] + DROP_HEIGHT_BLOCKS * (1D - fallen), block[2]);
+ }
+ } finally {
+ canvas.dispose();
+ }
+ }
+
+ private static double easeOut(double progress) {
+ double remaining = 1D - progress;
+ return 1D - remaining * remaining * remaining;
+ }
+
+ // The cube's middle, (1, 1, 1), lands on the centre point; the top and the two sides facing the viewer show.
+ private static void drawBlock(Graphics2D canvas, double centerX, double centerY, double size, double x, double y, double z) {
+ Color top = StudioTheme.ACCENT;
+ canvas.setColor(top);
+ canvas.fill(face(centerX, centerY, size, x, y, z, new double[][]{{0, 1, 0}, {1, 1, 0}, {1, 1, 1}, {0, 1, 1}}));
+ canvas.setColor(shaded(top, SIDE_SHADE));
+ canvas.fill(face(centerX, centerY, size, x, y, z, new double[][]{{1, 0, 0}, {1, 1, 0}, {1, 1, 1}, {1, 0, 1}}));
+ canvas.setColor(shaded(top, FAR_SIDE_SHADE));
+ canvas.fill(face(centerX, centerY, size, x, y, z, new double[][]{{0, 0, 1}, {1, 0, 1}, {1, 1, 1}, {0, 1, 1}}));
+ }
+
+ private static Path2D face(double centerX, double centerY, double size, double x, double y, double z, double[][] corners) {
+ Path2D path = new Path2D.Double();
+ for (int corner = 0; corner < corners.length; corner++) {
+ double cornerX = x + corners[corner][0] - 1D;
+ double cornerY = y + corners[corner][1] - 1D;
+ double cornerZ = z + corners[corner][2] - 1D;
+ double screenX = centerX + (cornerX - cornerZ) * ISOMETRIC_WIDTH * size;
+ double screenY = centerY + (cornerX + cornerZ) * ISOMETRIC_DEPTH * size - cornerY * size;
+ if (corner == 0) {
+ path.moveTo(screenX, screenY);
+ } else {
+ path.lineTo(screenX, screenY);
+ }
+ }
+ path.closePath();
+ return path;
+ }
+
+ private static Color shaded(Color color, float shade) {
+ return new Color(Math.round(color.getRed() * shade), Math.round(color.getGreen() * shade), Math.round(color.getBlue() * shade));
+ }
+}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/ObjectExplorer.java b/core/src/main/java/com/volmit/iris/core/gui/ObjectExplorer.java
index a650d48b4..77093c4d9 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/ObjectExplorer.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectExplorer.java
@@ -155,7 +155,7 @@ final class ObjectExplorer {
// Textures load first, since the first object would otherwise be drawn in flat colours.
void open() {
- statusBar.showActivity("Loading Minecraft textures...");
+ viewport.showLoading("Loading Minecraft textures");
loader.execute(() -> {
loadTextures();
List keys = objectKeys();
@@ -217,7 +217,7 @@ final class ObjectExplorer {
variantPicker.forgetTriedSeeds();
showVariantList(List.of(), OptionalLong.empty(), OptionalLong.empty(), 0);
}
- long revision = startLoading(key);
+ long revision = startBaking(key);
loader.execute(() -> {
if (revision != objectRevision.get()) {
return;
@@ -235,7 +235,7 @@ final class ObjectExplorer {
int variantIndex = shownVariantIndex;
shownSeed = OptionalLong.of(seed);
variantPicker.showSeed(shownSeed, pack);
- long revision = startLoading(key + " with seed " + seed);
+ long revision = startBaking(key + " with seed " + seed);
loader.execute(() -> {
if (revision == objectRevision.get()) {
loadVariants(revision, key, pack, OptionalLong.of(seed), variantIndex);
@@ -277,7 +277,7 @@ final class ObjectExplorer {
return;
}
if (variants.isEmpty()) {
- gallery.showMessage("Loading " + shownKey + "...");
+ gallery.showLoading();
} else if (variants.size() == 1) {
toggleComparing();
} else {
@@ -380,12 +380,19 @@ final class ObjectExplorer {
return new ObjectMesh.Bounds(0, 0, 0, sizeX, sizeY, sizeZ);
}
+ // A new list of variants is on its way, so every thumbnail is outdated, unlike when stepping to another variant.
+ private long startBaking(String key) {
+ if (comparingVariants) {
+ gallery.showLoading();
+ }
+ return startLoading(key);
+ }
+
private long startLoading(String key) {
shown = null;
wholeMesh = null;
meshRevision.incrementAndGet();
- viewport.showMessage("Loading " + key + "...");
- statusBar.showActivity("Loading " + key + "...");
+ viewport.showLoading("Loading " + key);
return objectRevision.incrementAndGet();
}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/ObjectVariantGallery.java b/core/src/main/java/com/volmit/iris/core/gui/ObjectVariantGallery.java
index a58c2a3ba..4900da7e0 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/ObjectVariantGallery.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectVariantGallery.java
@@ -25,6 +25,8 @@ import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.KeyStroke;
import javax.swing.Scrollable;
+import javax.swing.Timer;
+import java.awt.AlphaComposite;
import java.awt.BorderLayout;
import java.awt.Cursor;
import java.awt.Dimension;
@@ -44,7 +46,8 @@ import java.util.function.IntConsumer;
/**
* Every variant of a procedural object side by side, drawn at one scale so their sizes compare. Clicking one opens it
- * in the 3D view.
+ * in the 3D view. While new thumbnails are drawn, each card keeps its old one faded until its own arrives; cards
+ * still waiting after a moment play the loading animation, one after another like a wave.
*/
final class ObjectVariantGallery extends JPanel {
private static final long serialVersionUID = 6153024487310558262L;
@@ -54,14 +57,19 @@ final class ObjectVariantGallery extends JPanel {
private static final int EDGE_MARGIN = 16;
// The variant bar floats over the bottom edge, so the last row scrolls clear of it.
private static final int VARIANT_BAR_CLEARANCE = 64;
+ private static final float OUTDATED_THUMBNAIL_OPACITY = 0.35F;
+ private static final int THUMBNAIL_SIZE_PER_LOADING_BLOCK = 14;
+ private static final int LOADING_WAVE_STEP_MILLIS = 90;
private final JLabel title = new JLabel(" ");
private final CardGrid grid = new CardGrid();
private final IntConsumer onVariantOpened;
private final Runnable onThemeChanged;
private final List cards = new ArrayList<>();
+ private final Timer loadingTicker = new Timer(LoadingAnimation.FRAME_MILLIS, event -> repaintWaitingCards());
private int coveredLeftWidth;
private int selectedIndex;
+ private long waitingSinceMillis;
/**
* @param onVariantOpened told the index of a variant the user clicks
@@ -113,10 +121,33 @@ final class ObjectVariantGallery extends JPanel {
}
/**
- * Empty cards for {@code count} variants, waiting for their thumbnails.
+ * Cards for {@code count} variants, waiting for their thumbnails. The cards already shown stay, with their old
+ * thumbnails faded, so a reroll never blanks the view.
*/
void showVariants(String heading, int count, int selected) {
title.setText(heading);
+ if (count != cards.size()) {
+ replaceCards(count);
+ }
+ startWaiting();
+ select(selected);
+ }
+
+ /** Fades every thumbnail while something new loads. */
+ void showLoading() {
+ startWaiting();
+ }
+
+ void showThumbnail(int index, BufferedImage thumbnail) {
+ if (index < cards.size()) {
+ cards.get(index).showThumbnail(thumbnail);
+ }
+ if (cards.stream().noneMatch(VariantCard::isWaiting)) {
+ loadingTicker.stop();
+ }
+ }
+
+ private void replaceCards(int count) {
cards.clear();
grid.removeAll();
for (int index = 0; index < count; index++) {
@@ -124,14 +155,21 @@ final class ObjectVariantGallery extends JPanel {
cards.add(card);
grid.add(card);
}
- select(selected);
grid.revalidate();
grid.repaint();
}
- void showThumbnail(int index, BufferedImage thumbnail) {
- if (index < cards.size()) {
- cards.get(index).showThumbnail(thumbnail);
+ private void startWaiting() {
+ if (!loadingTicker.isRunning()) {
+ waitingSinceMillis = System.currentTimeMillis();
+ loadingTicker.start();
+ }
+ cards.forEach(VariantCard::markOutdated);
+ }
+
+ private void repaintWaitingCards() {
+ if (LoadingAnimation.isDue(waitingSinceMillis)) {
+ cards.stream().filter(VariantCard::isWaiting).forEach(VariantCard::repaint);
}
}
@@ -146,6 +184,7 @@ final class ObjectVariantGallery extends JPanel {
void showMessage(String text) {
title.setText(text);
+ loadingTicker.stop();
cards.clear();
grid.removeAll();
grid.revalidate();
@@ -230,6 +269,7 @@ final class ObjectVariantGallery extends JPanel {
private final int index;
private BufferedImage thumbnail;
+ private boolean thumbnailOutdated;
private boolean hovered;
VariantCard(int index) {
@@ -256,9 +296,19 @@ final class ObjectVariantGallery extends JPanel {
void showThumbnail(BufferedImage image) {
thumbnail = image;
+ thumbnailOutdated = false;
repaint();
}
+ void markOutdated() {
+ thumbnailOutdated = true;
+ repaint();
+ }
+
+ boolean isWaiting() {
+ return thumbnail == null || thumbnailOutdated;
+ }
+
private void setHovered(boolean nowHovered) {
hovered = nowHovered;
repaint();
@@ -277,12 +327,7 @@ final class ObjectVariantGallery extends JPanel {
int size = thumbnailSize();
canvas.setFont(StudioTheme.smallFont());
FontMetrics metrics = canvas.getFontMetrics();
- if (thumbnail != null) {
- canvas.drawImage(thumbnail, padding, padding, size, size, null);
- } else {
- canvas.setColor(StudioTheme.MUTED_TEXT);
- drawCentered(canvas, "Loading...", padding + size / 2);
- }
+ drawThumbnail(canvas, padding, size);
canvas.setColor(StudioTheme.TEXT);
drawCentered(canvas, "Variant " + (index + 1), padding * 2 + size + metrics.getAscent());
} finally {
@@ -290,6 +335,22 @@ final class ObjectVariantGallery extends JPanel {
}
}
+ private void drawThumbnail(Graphics2D canvas, int padding, int size) {
+ if (thumbnail != null) {
+ Graphics2D faded = (Graphics2D) canvas.create();
+ if (thumbnailOutdated) {
+ faded.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, OUTDATED_THUMBNAIL_OPACITY));
+ }
+ faded.drawImage(thumbnail, padding, padding, size, size, null);
+ faded.dispose();
+ }
+ if (isWaiting() && LoadingAnimation.isDue(waitingSinceMillis)) {
+ long shownMillis = System.currentTimeMillis() - waitingSinceMillis - LoadingAnimation.SHOW_AFTER_MILLIS;
+ LoadingAnimation.paint(canvas, padding + size / 2D, padding + size / 2D, size / (double) THUMBNAIL_SIZE_PER_LOADING_BLOCK,
+ shownMillis - (long) index * LOADING_WAVE_STEP_MILLIS);
+ }
+ }
+
private void drawCentered(Graphics2D canvas, String text, int baseline) {
canvas.drawString(text, (getWidth() - canvas.getFontMetrics().stringWidth(text)) / 2, baseline);
}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/ObjectViewport.java b/core/src/main/java/com/volmit/iris/core/gui/ObjectViewport.java
index c1ee7087c..7debd0f26 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/ObjectViewport.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectViewport.java
@@ -22,6 +22,8 @@ import javax.swing.AbstractAction;
import javax.swing.JComponent;
import javax.swing.KeyStroke;
import javax.swing.SwingUtilities;
+import javax.swing.Timer;
+import java.awt.AlphaComposite;
import java.awt.EventQueue;
import java.awt.Graphics;
import java.awt.Graphics2D;
@@ -42,12 +44,16 @@ import java.util.function.UnaryOperator;
/**
* The 3D view of one object. Drag to orbit, shift-drag or middle-drag to pan, scroll to zoom; arrow keys orbit and
* plus and minus zoom too. Frames are drawn on a background thread, one at a time, always for the latest camera.
+ * While the next object loads, the last picture stays up; only a slow load dims it under the loading animation.
*/
final class ObjectViewport extends JComponent {
private static final long serialVersionUID = -3175226517409913524L;
private static final double WHEEL_ZOOM_EXPONENT = 0.12D;
private static final double KEY_ZOOM_FACTOR = 1.1D;
private static final int KEY_ORBIT_PIXELS = 12;
+ private static final float LOADING_DIM_OPACITY = 0.88F;
+ private static final double LOADING_BLOCK_SIZE = 16D;
+ private static final int LOADING_TEXT_GAP = 46;
private final ExecutorService renderThread = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "Iris Object Viewer");
@@ -62,6 +68,9 @@ final class ObjectViewport extends JComponent {
private boolean renderAgain;
private Point dragPoint;
private int coveredLeftWidth;
+ private final Timer loadingTicker = new Timer(LoadingAnimation.FRAME_MILLIS, event -> repaint());
+ private String loadingText;
+ private long loadingSinceMillis;
ObjectViewport() {
setOpaque(true);
@@ -84,15 +93,37 @@ final class ObjectViewport extends JComponent {
requestFrame();
}
+ // The last picture stays up until the first one of this mesh is drawn.
void show(ObjectMesh nextMesh) {
mesh = nextMesh;
camera = ObjectCamera.START;
message = nextMesh.isEmpty() ? "No visible blocks" : null;
- frame = null;
+ if (nextMesh.isEmpty()) {
+ frame = null;
+ finishLoading();
+ }
requestFrame();
repaint();
}
+ /**
+ * Marks the view as loading something new. A load already under way keeps its start, so stepping quickly through
+ * objects still shows the animation once the wait adds up.
+ */
+ void showLoading(String text) {
+ if (loadingText == null) {
+ loadingSinceMillis = System.currentTimeMillis();
+ }
+ loadingText = text;
+ message = null;
+ loadingTicker.start();
+ }
+
+ private void finishLoading() {
+ loadingText = null;
+ loadingTicker.stop();
+ }
+
/**
* Swaps in another mesh of the same object, like one with its top layers cut away, keeping the camera.
*/
@@ -117,6 +148,7 @@ final class ObjectViewport extends JComponent {
mesh = null;
message = text;
frame = null;
+ finishLoading();
repaint();
}
@@ -136,6 +168,7 @@ final class ObjectViewport extends JComponent {
}
void close() {
+ loadingTicker.stop();
renderThread.shutdownNow();
}
@@ -168,6 +201,7 @@ final class ObjectViewport extends JComponent {
rendering = false;
if (frameMesh == mesh) {
frame = image;
+ finishLoading();
repaint();
}
if (renderAgain) {
@@ -183,11 +217,38 @@ final class ObjectViewport extends JComponent {
if (frame != null) {
graphics.drawImage(frame, 0, 0, null);
}
+ if (loadingText != null && LoadingAnimation.isDue(loadingSinceMillis)) {
+ drawLoading((Graphics2D) graphics);
+ }
if (message != null) {
drawMessage((Graphics2D) graphics);
}
}
+ private void drawLoading(Graphics2D graphics) {
+ Graphics2D canvas = (Graphics2D) graphics.create();
+ try {
+ if (frame != null) {
+ canvas.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, LOADING_DIM_OPACITY));
+ canvas.setColor(getBackground());
+ canvas.fillRect(0, 0, getWidth(), getHeight());
+ canvas.setComposite(AlphaComposite.SrcOver);
+ }
+ int centerX = coveredLeftWidth + (getWidth() - coveredLeftWidth) / 2;
+ int centerY = getHeight() / 2;
+ long shownMillis = System.currentTimeMillis() - loadingSinceMillis - LoadingAnimation.SHOW_AFTER_MILLIS;
+ LoadingAnimation.paint(canvas, centerX, centerY, StudioTheme.scale(LOADING_BLOCK_SIZE), shownMillis);
+ canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+ canvas.setFont(StudioTheme.font());
+ canvas.setColor(StudioTheme.MUTED_TEXT);
+ String text = loadingText + "...";
+ int textWidth = canvas.getFontMetrics().stringWidth(text);
+ canvas.drawString(text, centerX - textWidth / 2, centerY + StudioTheme.scale(LOADING_TEXT_GAP));
+ } finally {
+ canvas.dispose();
+ }
+ }
+
private void drawMessage(Graphics2D canvas) {
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
canvas.setFont(StudioTheme.font());