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 0ee1624a5..004ea9191 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 @@ -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 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 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 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 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 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 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 variants = shownVariants; + gallery.showVariants(galleryHeading(), variants.size(), shownVariantIndex); + int size = gallery.thumbnailSize(); + thumbnailPainter.execute(() -> { + List 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 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 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 visibleBlocks(IrisObject object) { Map 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 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()); diff --git a/core/src/main/java/com/volmit/iris/core/gui/ObjectRenderer.java b/core/src/main/java/com/volmit/iris/core/gui/ObjectRenderer.java index f5830df29..607cf2078 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/ObjectRenderer.java +++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectRenderer.java @@ -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 farthestFirst(List 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; } diff --git a/core/src/main/java/com/volmit/iris/core/gui/ObjectSource.java b/core/src/main/java/com/volmit/iris/core/gui/ObjectSource.java index bbcd05a8d..1c1f60d88 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/ObjectSource.java +++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectSource.java @@ -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 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 variants(IrisData data, String key, long seed) { + throw new UnsupportedOperationException(key + " is not generated from a seed"); + } } 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 new file mode 100644 index 000000000..a58c2a3ba --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectVariantGallery.java @@ -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 . + */ +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 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); + } + } +} diff --git a/core/src/main/java/com/volmit/iris/core/gui/ObjectVariantPicker.java b/core/src/main/java/com/volmit/iris/core/gui/ObjectVariantPicker.java index 87fd0350b..99690982e 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/ObjectVariantPicker.java +++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectVariantPicker.java @@ -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 diff --git a/core/src/main/java/com/volmit/iris/core/gui/ProceduralObjectSource.java b/core/src/main/java/com/volmit/iris/core/gui/ProceduralObjectSource.java index 6b55f8627..7aadcce39 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/ProceduralObjectSource.java +++ b/core/src/main/java/com/volmit/iris/core/gui/ProceduralObjectSource.java @@ -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 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(); + } } diff --git a/core/src/main/java/com/volmit/iris/engine/object/procedural/ProceduralObjectCatalog.java b/core/src/main/java/com/volmit/iris/engine/object/procedural/ProceduralObjectCatalog.java index 4aa81cdec..a9d0c14ed 100644 --- a/core/src/main/java/com/volmit/iris/engine/object/procedural/ProceduralObjectCatalog.java +++ b/core/src/main/java/com/volmit/iris/engine/object/procedural/ProceduralObjectCatalog.java @@ -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 byDefinition = new KMap<>(); + private final KMap 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) { diff --git a/core/src/main/java/com/volmit/iris/engine/object/procedural/ResolvedProceduralPlacement.java b/core/src/main/java/com/volmit/iris/engine/object/procedural/ResolvedProceduralPlacement.java index 91a701db0..a28503994 100644 --- a/core/src/main/java/com/volmit/iris/engine/object/procedural/ResolvedProceduralPlacement.java +++ b/core/src/main/java/com/volmit/iris/engine/object/procedural/ResolvedProceduralPlacement.java @@ -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(); } }