Compare procedural variants side by side

Grid at one scale; step seeds, back to pack seed.
This commit is contained in:
piexel committed 2026-10-21 13:46:25 +02:00
1 parent 03fd5e4bf3
commit f30f761ae6
8 files changed
+703 -71

No files matched your search

@@ -19,6 +19,7 @@
package com.volmit.iris.core.gui;
import com.volmit.iris.core.gui.BlockTextures.BlockTexture;
import com.volmit.iris.core.loader.IrisData;
import com.volmit.iris.engine.framework.Engine;
import com.volmit.iris.engine.object.IrisObject;
import org.bukkit.NamespacedKey;
@@ -26,13 +27,17 @@ import org.bukkit.block.data.BlockData;
import org.bukkit.util.BlockVector;
import javax.swing.JComponent;
import javax.swing.JPanel;
import java.awt.CardLayout;
import java.awt.EventQueue;
import java.awt.event.KeyEvent;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.OptionalLong;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@@ -42,12 +47,15 @@ import java.util.concurrent.atomic.AtomicLong;
/**
* The map window's Objects and Procedural views: everything its source offers in a folder tree, the chosen one in 3D,
* and what it is made of. Inspired by the Procedural Objects workbench.
* and what it is made of. A procedural object's variants can also be compared side by side, baked from any seed.
* Inspired by the Procedural Objects workbench.
*/
final class ObjectExplorer {
private static final NamespacedKey TINT_BIOME = NamespacedKey.minecraft("plains");
private static final int UNKNOWN_BLOCK_COLOR = 0x8A8A8A;
private static final String CAMERA_HINT = "Drag to orbit · Shift-drag to pan · Scroll to zoom · Page Up/Down cuts layers · R resets";
private static final String ONE_VARIANT_CARD = "one variant";
private static final String ALL_VARIANTS_CARD = "all variants";
private final Engine startEngine;
private final UUID worldId;
@@ -55,8 +63,11 @@ final class ObjectExplorer {
private final ObjectBrowser browser;
private final ObjectViewport viewport = new ObjectViewport();
private final ObjectLayerCut layerCut = new ObjectLayerCut(shownLayers -> showLayerCut());
private final ObjectVariantPicker variantPicker = new ObjectVariantPicker(this::showVariant);
private final JComponent viewWithOverlays = variantPicker.floatingOver(layerCut.floatingOver(viewport));
private final ObjectVariantPicker variantPicker = new ObjectVariantPicker(this::showVariant, this::showSeed, this::toggleComparing);
private final ObjectVariantGallery gallery = new ObjectVariantGallery(this::openVariant, this::redrawThumbnails);
private final CardLayout viewCards = new CardLayout();
private final JPanel views = new JPanel(viewCards);
private final JComponent viewWithOverlays;
private final ObjectOverviewPanel overview = new ObjectOverviewPanel(this::showSeeThrough);
private final StudioStatusBar statusBar = new StudioStatusBar();
private final ExecutorService loader = Executors.newSingleThreadExecutor(runnable -> {
@@ -65,7 +76,14 @@ final class ObjectExplorer {
thread.setPriority(Thread.MIN_PRIORITY);
return thread;
});
private final ExecutorService thumbnailPainter = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "Iris Variant Thumbnails");
thread.setDaemon(true);
thread.setPriority(Thread.MIN_PRIORITY);
return thread;
});
private final AtomicLong objectRevision = new AtomicLong();
private final AtomicLong variantListRevision = new AtomicLong();
private final AtomicLong meshRevision = new AtomicLong();
private final Map<String, BlockModel> models = new ConcurrentHashMap<>();
private final Runnable hotloadHook = this::reloadPack;
@@ -73,6 +91,11 @@ final class ObjectExplorer {
private volatile BiomeTintColors tintColors;
private String shownKey;
private List<IrisObject> shownVariants = List.of();
private int shownVariantIndex;
private OptionalLong packSeed = OptionalLong.empty();
private OptionalLong shownSeed = OptionalLong.empty();
private boolean comparingVariants;
private long thumbnailsDrawnForRevision = -1;
private LoadedObject shown;
private ObjectMesh wholeMesh;
private Set<String> seeThroughTypes = Set.of();
@@ -82,13 +105,22 @@ final class ObjectExplorer {
this.worldId = worldId;
this.source = source;
this.browser = new ObjectBrowser(source.noun(), this::showObject);
views.add(layerCut.floatingOver(viewport), ONE_VARIANT_CARD);
views.add(gallery, ALL_VARIANTS_CARD);
this.viewWithOverlays = variantPicker.floatingOver(views);
GuiHost.interruptOnPluginShutdown(loader);
GuiHost.interruptOnPluginShutdown(thumbnailPainter);
GuiHost.registerHotloadHook(hotloadHook);
statusBar.showPosition(CAMERA_HINT);
viewport.bindKey(KeyEvent.VK_PAGE_UP, "show-layer-above", () -> layerCut.stepBy(1));
viewport.bindKey(KeyEvent.VK_PAGE_DOWN, "hide-top-layer", () -> layerCut.stepBy(-1));
viewport.bindKey(KeyEvent.VK_OPEN_BRACKET, "previous-variant", () -> variantPicker.stepBy(-1));
viewport.bindKey(KeyEvent.VK_CLOSE_BRACKET, "next-variant", () -> variantPicker.stepBy(1));
viewport.bindKey(KeyEvent.VK_G, "compare-variants", this::toggleComparing);
gallery.bindKey(KeyEvent.VK_OPEN_BRACKET, "previous-variant", () -> variantPicker.stepBy(-1));
gallery.bindKey(KeyEvent.VK_CLOSE_BRACKET, "next-variant", () -> variantPicker.stepBy(1));
gallery.bindKey(KeyEvent.VK_G, "show-one-variant", this::toggleComparing);
gallery.bindKey(KeyEvent.VK_ESCAPE, "close-comparison", this::toggleComparing);
}
JComponent browser() {
@@ -96,7 +128,8 @@ final class ObjectExplorer {
}
/**
* The 3D view with the slider that cuts away its top layers and the bar that steps through variants.
* The 3D view with the slider that cuts away its top layers, or every variant side by side, under the bar that
* steps through variants and seeds.
*/
JComponent view() {
return viewWithOverlays;
@@ -104,6 +137,7 @@ final class ObjectExplorer {
void setCoveredLeftWidth(int width) {
viewport.setCoveredLeftWidth(width);
gallery.setCoveredLeftWidth(width);
variantPicker.setCoveredLeftWidth(width);
}
@@ -133,6 +167,7 @@ final class ObjectExplorer {
GuiHost.unregisterHotloadHook(hotloadHook);
objectRevision.incrementAndGet();
loader.shutdownNow();
thumbnailPainter.shutdownNow();
viewport.close();
}
@@ -158,42 +193,103 @@ final class ObjectExplorer {
viewport.showMessage("This pack has no " + source.noun() + "s");
overview.clear();
layerCut.showHeight(1);
variantPicker.showVariants(0);
if (comparingVariants) {
toggleComparing();
}
showVariantList(List.of(), OptionalLong.empty(), OptionalLong.empty(), 0);
return;
}
browser.select(key);
showObject(key);
}
/**
* Shows the first variant of the entry, baked from the pack's seed. The pack reloading the shown entry keeps the
* variant and the seed being looked at, unless the pack itself now sets another seed.
*/
private void showObject(String key) {
boolean reloadingShownObject = key.equals(shownKey);
OptionalLong previousPackSeed = packSeed;
OptionalLong exploredSeed = shownSeed;
int variantIndex = reloadingShownObject ? shownVariantIndex : 0;
shownKey = key;
shownVariants = List.of();
variantPicker.showVariants(0);
if (!reloadingShownObject) {
showVariantList(List.of(), OptionalLong.empty(), OptionalLong.empty(), 0);
}
long revision = startLoading(key);
loader.execute(() -> {
if (revision != objectRevision.get()) {
return;
}
try {
List<IrisObject> variants = source.variants(engine().getData(), key);
if (variants.isEmpty()) {
throw new IllegalStateException("Nothing could be loaded for " + key);
}
LoadedObject loaded = load(variants.get(0));
EventQueue.invokeLater(() -> {
if (revision == objectRevision.get()) {
shownVariants = variants;
variantPicker.showVariants(variants.size());
}
showLoaded(revision, loaded);
});
} catch (RuntimeException error) {
EventQueue.invokeLater(() -> showFailure(revision, key, error));
OptionalLong pack = source.seed(key);
OptionalLong seed = reloadingShownObject && pack.equals(previousPackSeed) ? exploredSeed : pack;
loadVariants(revision, key, pack, seed, variantIndex);
});
}
// The seed shows at once, so stepping again continues from it even while this one bakes or fails.
private void showSeed(long seed) {
String key = shownKey;
OptionalLong pack = packSeed;
int variantIndex = shownVariantIndex;
shownSeed = OptionalLong.of(seed);
variantPicker.showSeed(shownSeed, pack);
long revision = startLoading(key + " with seed " + seed);
loader.execute(() -> {
if (revision == objectRevision.get()) {
loadVariants(revision, key, pack, OptionalLong.of(seed), variantIndex);
}
});
}
// On the loader thread.
private void loadVariants(long revision, String key, OptionalLong pack, OptionalLong seed, int preferredIndex) {
try {
IrisData data = engine().getData();
List<IrisObject> variants = seed.isPresent() ? source.variants(data, key, seed.getAsLong()) : source.variants(data, key);
if (variants.isEmpty()) {
throw new IllegalStateException("Nothing could be loaded for " + key);
}
int index = preferredIndex < variants.size() ? preferredIndex : 0;
LoadedObject loaded = load(variants.get(index));
EventQueue.invokeLater(() -> {
if (revision != objectRevision.get()) {
return;
}
showVariantList(variants, pack, seed, index);
showLoaded(revision, loaded);
});
} catch (RuntimeException error) {
EventQueue.invokeLater(() -> showFailure(revision, key, error));
}
}
private void showVariantList(List<IrisObject> variants, OptionalLong pack, OptionalLong seed, int index) {
shownVariants = variants;
shownVariantIndex = index;
packSeed = pack;
shownSeed = seed;
variantListRevision.incrementAndGet();
variantPicker.showVariants(variants.size(), index);
variantPicker.showSeed(seed, pack);
if (!comparingVariants) {
return;
}
if (variants.isEmpty()) {
gallery.showMessage("Loading " + shownKey + "...");
} else if (variants.size() == 1) {
toggleComparing();
} else {
drawThumbnails();
}
}
private void showVariant(int index) {
if (index >= shownVariants.size()) {
return;
}
shownVariantIndex = index;
gallery.select(index);
IrisObject variant = shownVariants.get(index);
long revision = startLoading(variant.getLoadKey());
loader.execute(() -> {
@@ -205,6 +301,85 @@ final class ObjectExplorer {
});
}
private void openVariant(int index) {
variantPicker.showVariants(shownVariants.size(), index);
showVariant(index);
toggleComparing();
}
private void toggleComparing() {
if (!comparingVariants && shownVariants.size() < 2) {
return;
}
comparingVariants = !comparingVariants;
variantPicker.showComparing(comparingVariants);
viewCards.show(views, comparingVariants ? ALL_VARIANTS_CARD : ONE_VARIANT_CARD);
if (comparingVariants) {
drawThumbnails();
gallery.requestFocusInWindow();
} else {
viewport.requestFocusInWindow();
}
}
private void redrawThumbnails() {
thumbnailsDrawnForRevision = -1;
if (comparingVariants) {
drawThumbnails();
}
}
/**
* Fills the side-by-side view with every shown variant, all drawn at the scale that fits the largest, so their
* sizes compare. Thumbnails arrive one at a time on their own thread, leaving the loader free for the 3D view.
*/
private void drawThumbnails() {
long revision = variantListRevision.get();
if (revision == thumbnailsDrawnForRevision) {
return;
}
thumbnailsDrawnForRevision = revision;
List<IrisObject> variants = shownVariants;
gallery.showVariants(galleryHeading(), variants.size(), shownVariantIndex);
int size = gallery.thumbnailSize();
thumbnailPainter.execute(() -> {
List<ObjectMesh> meshes = new ArrayList<>();
for (IrisObject variant : variants) {
if (revision != variantListRevision.get()) {
return;
}
meshes.add(ObjectMesh.build(visibleBlocks(variant), this::modelOf));
}
ObjectMesh.Bounds sizeToFit = largestSize(meshes);
for (int index = 0; index < meshes.size() && revision == variantListRevision.get(); index++) {
BufferedImage thumbnail = ObjectRenderer.renderThumbnail(meshes.get(index), sizeToFit, size, size);
int shownIndex = index;
EventQueue.invokeLater(() -> {
if (revision == variantListRevision.get()) {
gallery.showThumbnail(shownIndex, thumbnail);
}
});
}
});
}
private String galleryHeading() {
String seed = shownSeed.isPresent() ? " · seed " + shownSeed.getAsLong() : "";
return String.format(Locale.ROOT, "%s · %d variants%s", shownKey, shownVariants.size(), seed);
}
private static ObjectMesh.Bounds largestSize(List<ObjectMesh> meshes) {
int sizeX = 1;
int sizeY = 1;
int sizeZ = 1;
for (ObjectMesh mesh : meshes) {
sizeX = Math.max(sizeX, mesh.bounds().sizeX());
sizeY = Math.max(sizeY, mesh.bounds().sizeY());
sizeZ = Math.max(sizeZ, mesh.bounds().sizeZ());
}
return new ObjectMesh.Bounds(0, 0, 0, sizeX, sizeY, sizeZ);
}
private long startLoading(String key) {
shown = null;
wholeMesh = null;
@@ -215,15 +390,20 @@ final class ObjectExplorer {
}
private LoadedObject load(IrisObject object) {
Map<BlockVector, BlockData> blocks = visibleBlocks(object);
ObjectMesh mesh = ObjectMesh.build(blocks, this::modelOf);
ObjectSummary summary = ObjectSummary.of(object.getLoadKey(), mesh.bounds(), blocks.values(), this::iconOf);
return new LoadedObject(blocks, mesh, summary);
}
private static Map<BlockVector, BlockData> visibleBlocks(IrisObject object) {
Map<BlockVector, BlockData> blocks = new HashMap<>();
object.getBlocks().forEach((position, block) -> {
if (!block.getMaterial().isAir()) {
blocks.put(position, block);
}
});
ObjectMesh mesh = ObjectMesh.build(blocks, this::modelOf);
ObjectSummary summary = ObjectSummary.of(object.getLoadKey(), mesh.bounds(), blocks.values(), this::iconOf);
return new LoadedObject(blocks, mesh, summary);
return blocks;
}
@@ -246,8 +426,9 @@ final class ObjectExplorer {
String cut = layerCut.isCutting()
? String.format(Locale.ROOT, " · showing layers 1-%d of %d", layerCut.shownLayerCount(), layerCut.layerCount())
: "";
statusBar.showSummary(String.format(Locale.ROOT, "%s · %,d blocks · %,d faces drawn%s",
summary.key(), summary.blockCount(), mesh.faceCount(), cut));
String seed = shownSeed.isPresent() ? " · seed " + shownSeed.getAsLong() : "";
statusBar.showSummary(String.format(Locale.ROOT, "%s%s · %,d blocks · %,d faces drawn%s",
summary.key(), seed, summary.blockCount(), mesh.faceCount(), cut));
}
private void showSeeThrough(Set<String> blockTypes) {
@@ -296,6 +477,8 @@ final class ObjectExplorer {
}
statusBar.showActivity("");
viewport.showMessage("Could not load " + key);
gallery.showMessage("Could not load " + key);
thumbnailsDrawnForRevision = -1;
overview.clear();
layerCut.showHeight(1);
statusBar.showError(error.getMessage() == null ? error.getClass().getSimpleName() : error.getMessage());
@@ -47,6 +47,8 @@ final class ObjectRenderer {
// A face's own texels end a hair inside its UV rectangle, so rounding never picks the texel next to it.
private static final double TEXEL_EDGE_MARGIN = 1e-6D;
private static final double FULL_ALPHA = 255D;
// Zoom 2 fills the picture with the object's bounding box; thumbnails keep a hair of margin around it.
private static final ObjectCamera THUMBNAIL_CAMERA = new ObjectCamera(ObjectCamera.START.yaw(), ObjectCamera.START.pitch(), 1.9D, 0D, 0D);
private final int width;
private final int height;
@@ -56,13 +58,15 @@ final class ObjectRenderer {
private final Projection projection;
private final int gridColor = GRID.getRGB() & 0xFFFFFF;
private ObjectRenderer(ObjectMesh mesh, ObjectCamera camera, BufferedImage image, int coveredLeftWidth) {
private ObjectRenderer(BufferedImage image, Projection projection) {
this.width = image.getWidth();
this.height = image.getHeight();
this.pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
this.depths = new double[pixels.length];
this.showsObject = new boolean[pixels.length];
this.projection = new Projection(mesh.bounds(), camera, width, height, coveredLeftWidth);
this.projection = projection;
Arrays.fill(pixels, StudioTheme.EDITOR_BACKGROUND.getRGB() & 0xFFFFFF);
Arrays.fill(depths, Double.NEGATIVE_INFINITY);
}
/**
@@ -70,21 +74,38 @@ final class ObjectRenderer {
* fitted in the rest
*/
static BufferedImage render(ObjectMesh mesh, ObjectCamera camera, int width, int height, int coveredLeftWidth) {
BufferedImage image = new BufferedImage(Math.max(1, width), Math.max(1, height), BufferedImage.TYPE_INT_RGB);
ObjectRenderer renderer = new ObjectRenderer(mesh, camera, image, Math.max(0, Math.min(width - 1, coveredLeftWidth)));
Arrays.fill(renderer.pixels, StudioTheme.EDITOR_BACKGROUND.getRGB() & 0xFFFFFF);
Arrays.fill(renderer.depths, Double.NEGATIVE_INFINITY);
BufferedImage image = blankImage(width, height);
int covered = Math.max(0, Math.min(image.getWidth() - 1, coveredLeftWidth));
ObjectRenderer renderer = new ObjectRenderer(image, new Projection(mesh.bounds(), mesh.bounds(), camera, image.getWidth(), image.getHeight(), covered));
renderer.drawGrid(mesh.bounds());
renderer.drawMesh(mesh);
return image;
}
/**
* Draws {@code mesh} from the starting angle, without the floor grid, at the scale that fits an object of
* {@code sizeToFit}. Objects drawn with the same size compare at their true sizes, standing on the same ground.
*/
static BufferedImage renderThumbnail(ObjectMesh mesh, ObjectMesh.Bounds sizeToFit, int width, int height) {
BufferedImage image = blankImage(width, height);
new ObjectRenderer(image, new Projection(mesh.bounds(), sizeToFit, THUMBNAIL_CAMERA, image.getWidth(), image.getHeight(), 0)).drawMesh(mesh);
return image;
}
private static BufferedImage blankImage(int width, int height) {
return new BufferedImage(Math.max(1, width), Math.max(1, height), BufferedImage.TYPE_INT_RGB);
}
private void drawMesh(ObjectMesh mesh) {
for (ObjectMesh.Quad quad : mesh.solidQuads()) {
renderer.drawQuad(quad, Pass.SOLID);
drawQuad(quad, Pass.SOLID);
}
for (ObjectMesh.Quad quad : mesh.cutAwayQuads()) {
renderer.drawQuad(quad, Pass.CUT_AWAY_GHOST);
drawQuad(quad, Pass.CUT_AWAY_GHOST);
}
for (ObjectMesh.Quad quad : renderer.farthestFirst(mesh.translucentQuads())) {
renderer.drawQuad(quad, Pass.BLENDED);
for (ObjectMesh.Quad quad : farthestFirst(mesh.translucentQuads())) {
drawQuad(quad, Pass.BLENDED);
}
return image;
}
private List<ObjectMesh.Quad> farthestFirst(List<ObjectMesh.Quad> quads) {
@@ -263,17 +284,21 @@ final class ObjectRenderer {
private final double screenCenterX;
private final double screenCenterY;
private Projection(ObjectMesh.Bounds bounds, ObjectCamera camera, int width, int height, int coveredLeftWidth) {
this.centerX = (bounds.minX() + bounds.maxX()) / 2D;
this.centerY = (bounds.minY() + bounds.maxY()) / 2D;
this.centerZ = (bounds.minZ() + bounds.maxZ()) / 2D;
/**
* @param shown the object drawn, centred across and standing where an object of {@code scaledAs} would
* @param scaledAs the size that is fitted to the view
*/
private Projection(ObjectMesh.Bounds shown, ObjectMesh.Bounds scaledAs, ObjectCamera camera, int width, int height, int coveredLeftWidth) {
this.centerX = (shown.minX() + shown.maxX()) / 2D;
this.centerY = shown.minY() + scaledAs.sizeY() / 2D;
this.centerZ = (shown.minZ() + shown.maxZ()) / 2D;
this.cosYaw = Math.cos(camera.yaw());
this.sinYaw = Math.sin(camera.yaw());
this.cosPitch = Math.cos(camera.pitch());
this.sinPitch = Math.sin(camera.pitch());
int visibleWidth = width - coveredLeftWidth;
double fit = fitAtStartingAngle(bounds, visibleWidth / (double) height);
this.pixelsPerBlock = camera.zoom() * fit * height / 2D / bounds.largestSize();
double fit = fitAtStartingAngle(scaledAs, visibleWidth / (double) height);
this.pixelsPerBlock = camera.zoom() * fit * height / 2D / scaledAs.largestSize();
this.screenCenterX = coveredLeftWidth + visibleWidth / 2D + camera.panX() * visibleWidth / 2D;
this.screenCenterY = height / 2D - camera.panY() * height / 2D;
}
@@ -21,6 +21,7 @@ import com.volmit.iris.core.loader.IrisData;
import com.volmit.iris.engine.object.IrisObject;
import java.util.List;
import java.util.OptionalLong;
/**
* Where an object explorer gets what it shows: the pack's object files, or the objects Iris generates from settings.
@@ -35,4 +36,14 @@ interface ObjectSource {
/** The objects of the entry, one for an object file and one per variant for a procedural object; empty when none load. */
List<IrisObject> variants(IrisData data, String key);
/** The seed the pack bakes the entry's variants from; empty for anything not generated from a seed. */
default OptionalLong seed(String key) {
return OptionalLong.empty();
}
/** The entry's variants as {@code seed} would bake them; only asked of entries that have a seed. */
default List<IrisObject> variants(IrisData data, String key, long seed) {
throw new UnsupportedOperationException(key + " is not generated from a seed");
}
}
@@ -0,0 +1,297 @@
/*
* 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 com.volmit.iris.core.gui;
import javax.swing.AbstractAction;
import javax.swing.BorderFactory;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.KeyStroke;
import javax.swing.Scrollable;
import java.awt.BorderLayout;
import java.awt.Cursor;
import java.awt.Dimension;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.event.ActionEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.geom.RoundRectangle2D;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.List;
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.
*/
final class ObjectVariantGallery extends JPanel {
private static final long serialVersionUID = 6153024487310558262L;
private static final int THUMBNAIL_SIZE = 220;
private static final int CARD_PADDING = 6;
private static final int CARD_GAP = 12;
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 final JLabel title = new JLabel(" ");
private final CardGrid grid = new CardGrid();
private final IntConsumer onVariantOpened;
private final Runnable onThemeChanged;
private final List<VariantCard> cards = new ArrayList<>();
private int coveredLeftWidth;
private int selectedIndex;
/**
* @param onVariantOpened told the index of a variant the user clicks
* @param onThemeChanged told when the theme changes, since thumbnails are drawn on the theme's background
*/
ObjectVariantGallery(IntConsumer onVariantOpened, Runnable onThemeChanged) {
super(new BorderLayout(0, StudioTheme.scale(EDGE_MARGIN)));
this.onVariantOpened = onVariantOpened;
this.onThemeChanged = onThemeChanged;
setOpaque(true);
setBackground(StudioTheme.EDITOR_BACKGROUND);
setFocusable(true);
title.setFont(StudioTheme.sectionFont());
title.setForeground(StudioTheme.TEXT);
JScrollPane scroll = StudioToolbar.scroll(grid);
scroll.setOpaque(false);
scroll.getViewport().setOpaque(false);
add(title, BorderLayout.NORTH);
add(scroll, BorderLayout.CENTER);
keepClearOfOverlays();
}
@Override
public void updateUI() {
super.updateUI();
if (onThemeChanged != null) {
onThemeChanged.run();
}
}
void bindKey(int keyCode, String name, Runnable action) {
getInputMap(WHEN_ANCESTOR_OF_FOCUSED_COMPONENT).put(KeyStroke.getKeyStroke(keyCode, 0), name);
getActionMap().put(name, new AbstractAction() {
@Override
public void actionPerformed(ActionEvent event) {
action.run();
}
});
}
void setCoveredLeftWidth(int width) {
coveredLeftWidth = width;
keepClearOfOverlays();
revalidate();
}
int thumbnailSize() {
return StudioTheme.scale(THUMBNAIL_SIZE);
}
/**
* Empty cards for {@code count} variants, waiting for their thumbnails.
*/
void showVariants(String heading, int count, int selected) {
title.setText(heading);
cards.clear();
grid.removeAll();
for (int index = 0; index < count; index++) {
VariantCard card = new VariantCard(index);
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);
}
}
void select(int index) {
selectedIndex = index;
cards.forEach(VariantCard::repaint);
if (index < cards.size()) {
VariantCard card = cards.get(index);
grid.scrollRectToVisible(card.getBounds());
}
}
void showMessage(String text) {
title.setText(text);
cards.clear();
grid.removeAll();
grid.revalidate();
grid.repaint();
}
private void keepClearOfOverlays() {
int margin = StudioTheme.scale(EDGE_MARGIN);
setBorder(BorderFactory.createEmptyBorder(margin, coveredLeftWidth + margin, StudioTheme.scale(VARIANT_BAR_CLEARANCE), margin));
}
/**
* Cards in as many columns as fit the width, wrapping onto more rows.
*/
private final class CardGrid extends JPanel implements Scrollable {
private static final long serialVersionUID = -3618705187215931408L;
CardGrid() {
super(null);
setOpaque(false);
}
@Override
public void doLayout() {
Dimension card = cardSize();
int gap = StudioTheme.scale(CARD_GAP);
int columns = columns();
for (int index = 0; index < getComponentCount(); index++) {
int column = index % columns;
int row = index / columns;
getComponent(index).setBounds(column * (card.width + gap), row * (card.height + gap), card.width, card.height);
}
}
@Override
public Dimension getPreferredSize() {
Dimension card = cardSize();
int gap = StudioTheme.scale(CARD_GAP);
int rows = (getComponentCount() + columns() - 1) / columns();
return new Dimension(card.width, Math.max(0, rows * (card.height + gap) - gap));
}
private int columns() {
int gap = StudioTheme.scale(CARD_GAP);
return Math.max(1, (getWidth() + gap) / (cardSize().width + gap));
}
@Override
public Dimension getPreferredScrollableViewportSize() {
return getPreferredSize();
}
@Override
public int getScrollableUnitIncrement(Rectangle visibleRect, int orientation, int direction) {
return cardSize().height / 4;
}
@Override
public int getScrollableBlockIncrement(Rectangle visibleRect, int orientation, int direction) {
return visibleRect.height;
}
@Override
public boolean getScrollableTracksViewportWidth() {
return true;
}
@Override
public boolean getScrollableTracksViewportHeight() {
return false;
}
}
private Dimension cardSize() {
int padding = StudioTheme.scale(CARD_PADDING);
int labelHeight = getFontMetrics(StudioTheme.smallFont()).getHeight();
return new Dimension(thumbnailSize() + 2 * padding, thumbnailSize() + labelHeight + 3 * padding);
}
private final class VariantCard extends JComponent {
private static final long serialVersionUID = 2296135546207834180L;
private final int index;
private BufferedImage thumbnail;
private boolean hovered;
VariantCard(int index) {
this.index = index;
setToolTipText("Open variant " + (index + 1) + " in 3D");
setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
addMouseListener(new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent event) {
onVariantOpened.accept(VariantCard.this.index);
}
@Override
public void mouseEntered(MouseEvent event) {
setHovered(true);
}
@Override
public void mouseExited(MouseEvent event) {
setHovered(false);
}
});
}
void showThumbnail(BufferedImage image) {
thumbnail = image;
repaint();
}
private void setHovered(boolean nowHovered) {
hovered = nowHovered;
repaint();
}
@Override
protected void paintComponent(Graphics graphics) {
Graphics2D canvas = (Graphics2D) graphics.create();
try {
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
double arc = StudioTheme.scale(StudioTheme.OVERLAY_ARC);
canvas.setColor(index == selectedIndex ? StudioTheme.SELECTION : hovered ? StudioTheme.HOVER_BACKGROUND : StudioTheme.PANEL_BACKGROUND);
canvas.fill(new RoundRectangle2D.Double(0, 0, getWidth(), getHeight(), arc, arc));
int padding = StudioTheme.scale(CARD_PADDING);
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);
}
canvas.setColor(StudioTheme.TEXT);
drawCentered(canvas, "Variant " + (index + 1), padding * 2 + size + metrics.getAscent());
} finally {
canvas.dispose();
}
}
private void drawCentered(Graphics2D canvas, String text, int baseline) {
canvas.drawString(text, (getWidth() - canvas.getFontMetrics().stringWidth(text)) / 2, baseline);
}
}
}
@@ -17,6 +17,9 @@
*/
package com.volmit.iris.core.gui;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JPanel;
@@ -27,39 +30,82 @@ import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.RoundRectangle2D;
import java.util.OptionalLong;
import java.util.function.IntConsumer;
import java.util.function.LongConsumer;
/**
* A small bar floating at the bottom of the 3D view that steps through the variants of a procedural object. It hides
* itself for anything with a single variant.
* A small bar floating at the bottom of the object view that steps through the variants of a procedural object and
* the seeds they are baked from, and switches between one variant in 3D and all of them side by side. It hides itself
* for anything with a single variant and no seed.
*/
final class ObjectVariantPicker extends JPanel {
private static final long serialVersionUID = -2716301839488217455L;
private static final int BOTTOM_MARGIN = 14;
private static final int LABEL_WIDTH = 110;
private static final String SHORTCUT_HINT = " · [ and ]";
private static final int VARIANT_LABEL_WIDTH = 110;
private static final int SEED_LABEL_WIDTH = 100;
private static final int SECTION_GAP = 10;
private static final String VARIANT_SHORTCUT_HINT = " · [ and ]";
private static final String COMPARE_SHORTCUT_HINT = " · G";
private final JLabel shownVariant = new JLabel(" ", SwingConstants.CENTER);
private final JLabel shownVariant = label(VARIANT_LABEL_WIDTH);
private final JLabel shownSeed = label(SEED_LABEL_WIDTH);
private final JComponent variantStepper;
private final JComponent seedStepper;
private final JButton packSeedButton;
private final JButton compareButton;
private final IntConsumer onVariantChosen;
private final LongConsumer onSeedChosen;
private int variantCount;
private int shownIndex;
private OptionalLong seed = OptionalLong.empty();
private OptionalLong packSeed = OptionalLong.empty();
private int coveredLeftWidth;
/**
* @param onVariantChosen told the index of the variant to show whenever the user steps to another one
* @param onSeedChosen told the seed to bake the variants from whenever the user steps to another one
* @param onCompareToggled told when the user switches between one variant and all of them
*/
ObjectVariantPicker(IntConsumer onVariantChosen) {
super(new BorderLayout());
ObjectVariantPicker(IntConsumer onVariantChosen, LongConsumer onSeedChosen, Runnable onCompareToggled) {
this.onVariantChosen = onVariantChosen;
this.onSeedChosen = onSeedChosen;
setLayout(new BoxLayout(this, BoxLayout.X_AXIS));
setOpaque(false);
setBorder(StudioTheme.emptyBorder(3, 4, 3, 4));
shownVariant.setFont(StudioTheme.smallFont());
shownVariant.setForeground(StudioTheme.TEXT);
shownVariant.setPreferredSize(new Dimension(StudioTheme.scale(LABEL_WIDTH), shownVariant.getPreferredSize().height));
add(StudioToolbar.button("‹", "Previous variant" + SHORTCUT_HINT, () -> stepBy(-1)), BorderLayout.WEST);
add(shownVariant, BorderLayout.CENTER);
add(StudioToolbar.button("›", "Next variant" + SHORTCUT_HINT, () -> stepBy(1)), BorderLayout.EAST);
showVariants(0);
variantStepper = stepper(shownVariant,
StudioToolbar.button("‹", "Previous variant" + VARIANT_SHORTCUT_HINT, () -> stepBy(-1)),
StudioToolbar.button("›", "Next variant" + VARIANT_SHORTCUT_HINT, () -> stepBy(1)));
seedStepper = stepper(shownSeed,
StudioToolbar.button("‹", "Bake the variants from the previous seed", () -> stepSeedBy(-1)),
StudioToolbar.button("›", "Bake the variants from the next seed", () -> stepSeedBy(1)));
packSeedButton = StudioToolbar.button("Pack seed", "", () -> packSeed.ifPresent(onSeedChosen::accept));
compareButton = StudioToolbar.button("", "", onCompareToggled);
add(variantStepper);
add(Box.createHorizontalStrut(StudioTheme.scale(SECTION_GAP)));
add(seedStepper);
add(packSeedButton);
add(Box.createHorizontalStrut(StudioTheme.scale(SECTION_GAP)));
add(compareButton);
showComparing(false);
showVariants(0, 0);
}
private static JLabel label(int width) {
JLabel label = new JLabel(" ", SwingConstants.CENTER);
label.setFont(StudioTheme.smallFont());
label.setForeground(StudioTheme.TEXT);
label.setPreferredSize(new Dimension(StudioTheme.scale(width), label.getPreferredSize().height));
return label;
}
private static JComponent stepper(JLabel shown, JButton previous, JButton next) {
JPanel stepper = new JPanel(new BorderLayout());
stepper.setOpaque(false);
stepper.add(previous, BorderLayout.WEST);
stepper.add(shown, BorderLayout.CENTER);
stepper.add(next, BorderLayout.EAST);
return stepper;
}
/**
@@ -91,19 +137,39 @@ final class ObjectVariantPicker extends JPanel {
void setCoveredLeftWidth(int width) {
coveredLeftWidth = width;
if (getParent() != null) {
getParent().revalidate();
}
relayout();
}
/**
* Starts again at the first of {@code count} variants without telling anyone, since the first is what loads anyway.
* Shows variant {@code index} of {@code count} without telling anyone, since the caller already shows it.
*/
void showVariants(int count) {
void showVariants(int count, int index) {
variantCount = count;
shownIndex = 0;
showLabel();
setVisible(count > 1);
shownIndex = index;
shownVariant.setText("Variant " + (index + 1) + " of " + count);
variantStepper.setVisible(count > 1);
compareButton.setVisible(count > 1);
showWhenUseful();
}
/**
* Shows the seed the variants are baked from, with a way back to the pack's own seed once another is chosen.
*/
void showSeed(OptionalLong shown, OptionalLong pack) {
seed = shown;
packSeed = pack;
shownSeed.setText(shown.isPresent() ? "Seed " + shown.getAsLong() : " ");
seedStepper.setVisible(shown.isPresent());
boolean explored = shown.isPresent() && !shown.equals(pack);
packSeedButton.setVisible(explored);
pack.ifPresent(value -> packSeedButton.setToolTipText("Back to the seed the pack sets, " + value));
showWhenUseful();
}
void showComparing(boolean comparing) {
compareButton.setText(comparing ? "3D view" : "All variants");
compareButton.setToolTipText((comparing ? "Show the chosen variant in 3D" : "Show every variant side by side") + COMPARE_SHORTCUT_HINT);
relayout();
}
void stepBy(int step) {
@@ -111,12 +177,24 @@ final class ObjectVariantPicker extends JPanel {
return;
}
shownIndex = Math.floorMod(shownIndex + step, variantCount);
showLabel();
showVariants(variantCount, shownIndex);
onVariantChosen.accept(shownIndex);
}
private void showLabel() {
shownVariant.setText("Variant " + (shownIndex + 1) + " of " + variantCount);
private void stepSeedBy(int step) {
seed.ifPresent(current -> onSeedChosen.accept(current + step));
}
private void showWhenUseful() {
setVisible(variantCount > 1 || seed.isPresent());
relayout();
}
private void relayout() {
if (getParent() != null) {
getParent().revalidate();
getParent().repaint();
}
}
@Override
@@ -25,6 +25,7 @@ import com.volmit.iris.engine.object.procedural.ResolvedProceduralPlacement;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.OptionalLong;
/**
* The objects the pack's procedural settings generate, one entry per definition, in the folders the pack keeps them in.
@@ -63,4 +64,20 @@ final class ProceduralObjectSource implements ObjectSource {
ResolvedProceduralPlacement definition = definitionsByKey.get(key);
return definition == null ? List.of() : definition.variants();
}
@Override
public OptionalLong seed(String key) {
ResolvedProceduralPlacement definition = definitionsByKey.get(key);
return definition == null ? OptionalLong.empty() : OptionalLong.of(definition.seed());
}
// The pack's own seed reuses the variants world generation bakes.
@Override
public List<IrisObject> variants(IrisData data, String key, long seed) {
ResolvedProceduralPlacement definition = definitionsByKey.get(key);
if (definition == null) {
return List.of();
}
return definition.seed() == seed ? definition.variants() : data.getProceduralObjects().reseeded(definition, seed).variants();
}
}
@@ -18,6 +18,7 @@
package com.volmit.iris.engine.object.procedural;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.volmit.iris.Iris;
import com.volmit.iris.core.loader.IrisData;
import com.volmit.iris.engine.object.IrisBiome;
@@ -73,6 +74,7 @@ public final class ProceduralObjectCatalog {
private final IrisData data;
private final KMap<String, ResolvedProceduralPlacement> byDefinition = new KMap<>();
private final KMap<ResolvedProceduralPlacement, Object> definitionByPlacement = new KMap<>();
public ProceduralObjectCatalog(IrisData data) {
this.data = data;
@@ -89,7 +91,25 @@ public final class ProceduralObjectCatalog {
private ResolvedProceduralPlacement placementOf(Object definition) {
String identity = definition.getClass().getName() + DEFINITION_JSON.toJson(definition);
return byDefinition.computeIfAbsent(identity, ignored -> resolvedFor(definition));
return byDefinition.computeIfAbsent(identity, ignored -> {
ResolvedProceduralPlacement placement = resolvedFor(definition);
definitionByPlacement.put(placement, definition);
return placement;
});
}
/**
* The same definition as {@code placement}, baked from {@code seed} instead of its own, to compare seeds before
* choosing one. It is not kept, since a viewer may step through many seeds.
*/
public ResolvedProceduralPlacement reseeded(ResolvedProceduralPlacement placement, long seed) {
Object definition = definitionByPlacement.get(placement);
if (definition == null) {
throw new IllegalArgumentException("The pack was reloaded since " + placement.name() + " was read");
}
JsonObject reseededJson = DEFINITION_JSON.toJsonTree(definition).getAsJsonObject();
reseededJson.addProperty("seed", seed);
return resolvedFor(data.getGson().fromJson(reseededJson, definition.getClass()));
}
private ResolvedProceduralPlacement resolvedFor(Object definition) {
@@ -160,7 +160,8 @@ public abstract class ResolvedProceduralPlacement {
return new RNG(shape.seed() + (index * VARIANT_SEED_STRIDE));
}
protected final long seed() {
/** The seed every variant is baked from. */
public final long seed() {
return shape.seed();
}
}