diff --git a/core/src/main/java/com/volmit/iris/core/commands/CommandStudio.java b/core/src/main/java/com/volmit/iris/core/commands/CommandStudio.java index 55d17e50d..6c9f5b7ff 100644 --- a/core/src/main/java/com/volmit/iris/core/commands/CommandStudio.java +++ b/core/src/main/java/com/volmit/iris/core/commands/CommandStudio.java @@ -252,6 +252,16 @@ public class CommandStudio implements DecreeExecutor { NoiseExplorerGUI.launch(); } + @Decree(description = "Preview biome palette layers (External GUI)", aliases = "pal") + public void palette( + @Param(description = "The biome to open on", contextual = true) + IrisBiome biome + ) { + if (noGUI()) return; + sender().sendMessage(C.GREEN + "Opening Palette Explorer!"); + NoiseExplorerGUI.launchPalettes(biome == null ? null : biome.getLoadKey()); + } + @Decree(description = "Charges all spawners in the area", aliases = "zzt", origin = DecreeOrigin.PLAYER) public void charge() { if (!IrisToolbelt.isIrisWorld(world())) { diff --git a/core/src/main/java/com/volmit/iris/core/gui/BlockPreviewColors.java b/core/src/main/java/com/volmit/iris/core/gui/BlockPreviewColors.java new file mode 100644 index 000000000..292cfff3f --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/BlockPreviewColors.java @@ -0,0 +1,101 @@ +/* + * 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 org.bukkit.block.data.BlockData; + +import java.awt.Color; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +final class BlockPreviewColors { + private static final Method MAP_COLOR_METHOD = findMapColorMethod(); + private static final double[] MAP_SHADES = {1D, 0.86D, 0.71D, 0.53D}; + private static final int NO_MAP_COLOR = 0; + + private BlockPreviewColors() { + } + + static Map colorsByBlock(List blocks) { + Map colors = new HashMap<>(); + List usedColors = new ArrayList<>(); + for (BlockData block : blocks) { + String key = block.getAsString(true); + if (colors.containsKey(key)) { + continue; + } + int baseColor = baseColor(block); + int color = firstUnusedShade(baseColor, usedColors); + usedColors.add(color); + colors.put(key, color); + } + return colors; + } + + // Blocks that share a map colour, like sand and sandstone, get darker shades the way Minecraft maps shade them. + private static int firstUnusedShade(int baseColor, List usedColors) { + for (double shade : MAP_SHADES) { + int shaded = shade(baseColor, shade); + if (!usedColors.contains(shaded)) { + return shaded; + } + } + return baseColor; + } + + private static int baseColor(BlockData block) { + int mapColor = mapColor(block); + return mapColor == NO_MAP_COLOR ? colorFromName(block.getMaterial().getKey().getKey()) : mapColor; + } + + private static int mapColor(BlockData block) { + if (MAP_COLOR_METHOD == null) { + return NO_MAP_COLOR; + } + try { + return ((org.bukkit.Color) MAP_COLOR_METHOD.invoke(block)).asRGB(); + } catch (ReflectiveOperationException | RuntimeException error) { + return NO_MAP_COLOR; + } + } + + private static int colorFromName(String name) { + float hue = (name.hashCode() & 0xFFFF) / (float) 0xFFFF; + return Color.HSBtoRGB(hue, 0.45F, 0.75F) & 0xFFFFFF; + } + + static int shade(int color, double shade) { + int red = (int) Math.min(255, ((color >>> 16) & 0xFF) * shade); + int green = (int) Math.min(255, ((color >>> 8) & 0xFF) * shade); + int blue = (int) Math.min(255, (color & 0xFF) * shade); + return (red << 16) | (green << 8) | blue; + } + + // Iris compiles against the 1.20.1 API, which predates BlockData#getMapColor. + private static Method findMapColorMethod() { + try { + return BlockData.class.getMethod("getMapColor"); + } catch (NoSuchMethodException error) { + return null; + } + } +} diff --git a/core/src/main/java/com/volmit/iris/core/gui/NoiseColorScale.java b/core/src/main/java/com/volmit/iris/core/gui/NoiseColorScale.java new file mode 100644 index 000000000..a10fcb369 --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/NoiseColorScale.java @@ -0,0 +1,35 @@ +/* + * 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.Color; + +interface NoiseColorScale { + int colorFinite(double value); + + int colorInvalid(); + + Color displayColorNormalized(double normalized); + + String label(); + + double minimum(); + + double maximum(); +} diff --git a/core/src/main/java/com/volmit/iris/core/gui/NoiseExplorerGUI.java b/core/src/main/java/com/volmit/iris/core/gui/NoiseExplorerGUI.java index 50873eb76..e8c40e3e9 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/NoiseExplorerGUI.java +++ b/core/src/main/java/com/volmit/iris/core/gui/NoiseExplorerGUI.java @@ -22,6 +22,8 @@ import com.volmit.iris.Iris; import com.volmit.iris.core.IrisSettings; import com.volmit.iris.core.loader.IrisData; import com.volmit.iris.engine.framework.Engine; +import com.volmit.iris.engine.object.IrisBiome; +import com.volmit.iris.engine.object.IrisBiomePaletteLayer; import com.volmit.iris.engine.object.IrisGenerator; import com.volmit.iris.engine.object.NoiseStyle; import com.volmit.iris.util.function.NoiseProvider; @@ -82,26 +84,27 @@ import java.util.function.LongFunction; public final class NoiseExplorerGUI extends JPanel { private static final long serialVersionUID = 2094606939770332040L; private static final Color BACKGROUND = new Color(12, 15, 22); - private static final Color SIDEBAR_BACKGROUND = new Color(17, 20, 29); - private static final Color PANEL_BACKGROUND = new Color(23, 27, 38); - private static final Color FIELD_BACKGROUND = new Color(29, 34, 47); + static final Color SIDEBAR_BACKGROUND = new Color(17, 20, 29); + static final Color PANEL_BACKGROUND = new Color(23, 27, 38); + static final Color FIELD_BACKGROUND = new Color(29, 34, 47); private static final Color SELECTED_BACKGROUND = new Color(42, 55, 78); - private static final Color PRIMARY_TEXT = new Color(230, 234, 242); - private static final Color SECONDARY_TEXT = new Color(151, 160, 178); - private static final Color ACCENT = new Color(99, 161, 255); - private static final Color BORDER = new Color(48, 55, 72); - private static final Color ERROR = new Color(255, 116, 116); + static final Color PRIMARY_TEXT = new Color(230, 234, 242); + static final Color SECONDARY_TEXT = new Color(151, 160, 178); + static final Color ACCENT = new Color(99, 161, 255); + static final Color BORDER = new Color(48, 55, 72); + static final Color ERROR = new Color(255, 116, 116); private static final Color GRID = new Color(255, 255, 255, 15); private static final Color CROSSHAIR = new Color(255, 255, 255, 125); private static final Color LEGEND_BACKGROUND = new Color(10, 12, 18, 215); private static final Font MONOSPACED_FONT = new Font(Font.MONOSPACED, Font.PLAIN, 12); - private static final Font HEADER_FONT = new Font(Font.SANS_SERIF, Font.BOLD, 11); - private static final Font ITEM_FONT = new Font(Font.SANS_SERIF, Font.PLAIN, 12); - private static final Font LEGEND_FONT = new Font(Font.SANS_SERIF, Font.PLAIN, 10); + static final Font HEADER_FONT = new Font(Font.SANS_SERIF, Font.BOLD, 11); + static final Font ITEM_FONT = new Font(Font.SANS_SERIF, Font.PLAIN, 12); + static final Font LEGEND_FONT = new Font(Font.SANS_SERIF, Font.PLAIN, 10); private static final int SIDEBAR_WIDTH = 270; private static final int STATUS_HEIGHT = 50; private static final int REFINEMENT_DELAY_MILLIS = 180; private static final long DEFAULT_SEED = 12345L; + private static final int SUMMARIZED_BLOCK_COUNT = 3; private static final long COARSE_SAMPLE_BUDGET = 4_096L; private static final long MAX_REFINED_SAMPLE_BUDGET = 48_000L; private static final double REFINEMENT_TARGET_MILLIS = 250D; @@ -109,6 +112,7 @@ public final class NoiseExplorerGUI extends JPanel { private static final NoiseCategory[] CATEGORY_ORDER = { NoiseCategory.CUSTOM, NoiseCategory.PACK_GENERATORS, + NoiseCategory.PACK_PALETTES, NoiseCategory.SIMPLEX, NoiseCategory.PERLIN, NoiseCategory.CELLULAR, @@ -126,6 +130,8 @@ public final class NoiseExplorerGUI extends JPanel { NoiseCategory.OTHER }; + private final ExplorerMode mode; + private final String initialPackSourceId; private volatile Engine activeEngine; private final LongFunction customFactory; private final String customName; @@ -146,12 +152,15 @@ public final class NoiseExplorerGUI extends JPanel { private final JLabel progressLabel = new JLabel("Opening noise explorer..."); private final JLabel catalogLabel = new JLabel("Pack catalog waiting"); private final JComboBox palettePicker = new JComboBox<>(NoisePalette.values()); + private final PaletteInspectorPanel paletteInspector = new PaletteInspectorPanel(new PaletteInspectorListener()); private List packSources = List.of(); private NoiseViewport viewport = new NoiseViewport(0D, 0D, 1D); private NoisePalette palette; private SourceItem selectedSource; private NoiseProvider sampler; + private PaletteLayerPreview palettePreview; + private int paletteDepth; private NoiseRenderCoordinator.Result renderResult; private BufferedImage renderedImage; private Point pointer; @@ -162,10 +171,14 @@ public final class NoiseExplorerGUI extends JPanel { private boolean sourceLoading; private boolean rendering; private boolean disposed; + private boolean awaitingInitialPackSource; private String errorMessage; - private NoiseExplorerGUI(Engine engine, String customGeneratorKey, IrisGenerator fallbackGenerator, - String customName, long initialSeed) { + private NoiseExplorerGUI(ExplorerMode mode, Engine engine, String customGeneratorKey, IrisGenerator fallbackGenerator, + String customName, long initialSeed, String initialPackSourceId) { + this.mode = mode; + this.initialPackSourceId = initialPackSourceId; + this.awaitingInitialPackSource = mode == ExplorerMode.PALETTES; this.activeEngine = engine; this.customFactory = fallbackGenerator == null ? null @@ -193,21 +206,33 @@ public final class NoiseExplorerGUI extends JPanel { public static void launch() { Engine engine = GuiHost.findActiveEngine(); EventQueue.invokeLater(() -> { - NoiseExplorerGUI explorer = new NoiseExplorerGUI(engine, null, null, null, DEFAULT_SEED); + NoiseExplorerGUI explorer = new NoiseExplorerGUI(ExplorerMode.NOISE, engine, null, null, null, DEFAULT_SEED, null); buildFrame("Noise Explorer", explorer); }); } + public static void launchPalettes(String biomeKey) { + Engine engine = GuiHost.findActiveEngine(); + String initialSourceId = biomeKey == null ? null : paletteSourceId(biomeKey, 0); + String title = biomeKey == null ? "Palette Explorer" : "Palette Explorer: " + biomeKey; + EventQueue.invokeLater(() -> { + NoiseExplorerGUI explorer = new NoiseExplorerGUI(ExplorerMode.PALETTES, engine, null, null, null, DEFAULT_SEED, initialSourceId); + buildFrame(title, explorer); + }); + } + public static void launchGeneratorKey(String generatorKey, IrisGenerator fallbackGenerator, long initialSeed) { Engine engine = GuiHost.findActiveEngine(); String displayName = generatorKey == null || generatorKey.isBlank() ? "Custom Generator" : generatorKey; EventQueue.invokeLater(() -> { NoiseExplorerGUI explorer = new NoiseExplorerGUI( + ExplorerMode.NOISE, engine, generatorKey, fallbackGenerator, displayName, - initialSeed + initialSeed, + null ); buildFrame("Noise Explorer: " + displayName, explorer); }); @@ -219,6 +244,7 @@ public final class NoiseExplorerGUI extends JPanel { frame.setLayout(new BorderLayout()); frame.add(explorer.buildSidebar(), BorderLayout.WEST); frame.add(explorer, BorderLayout.CENTER); + frame.add(explorer.paletteInspector, BorderLayout.EAST); frame.setSize(1440, 820); frame.setMinimumSize(new Dimension(760, 500)); frame.setLocationRelativeTo(null); @@ -339,21 +365,11 @@ public final class NoiseExplorerGUI extends JPanel { catalogLabel.setFont(ITEM_FONT); controls.add(catalogLabel); - JLabel seedTitle = new JLabel("Seed"); - seedTitle.setForeground(SECONDARY_TEXT); - seedTitle.setFont(HEADER_FONT); - controls.add(seedTitle); - JPanel seedRow = new JPanel(new BorderLayout(7, 0)); - seedRow.setOpaque(false); - configureTextField(seedField); - JButton applySeed = createButton("Apply"); - applySeed.addActionListener(event -> applySeed()); - seedField.addActionListener(event -> applySeed()); - seedRow.add(seedField, BorderLayout.CENTER); - seedRow.add(applySeed, BorderLayout.EAST); - controls.add(seedRow); + if (mode == ExplorerMode.NOISE) { + addSeedControls(controls); + } - JLabel paletteTitle = new JLabel("Palette"); + JLabel paletteTitle = new JLabel(mode == ExplorerMode.PALETTES ? "Noise view colours" : "Palette"); paletteTitle.setForeground(SECONDARY_TEXT); paletteTitle.setFont(HEADER_FONT); controls.add(paletteTitle); @@ -375,6 +391,22 @@ public final class NoiseExplorerGUI extends JPanel { return controls; } + private void addSeedControls(JPanel controls) { + JLabel seedTitle = new JLabel("Seed"); + seedTitle.setForeground(SECONDARY_TEXT); + seedTitle.setFont(HEADER_FONT); + controls.add(seedTitle); + JPanel seedRow = new JPanel(new BorderLayout(7, 0)); + seedRow.setOpaque(false); + configureTextField(seedField); + JButton applySeed = createButton("Apply"); + applySeed.addActionListener(event -> applySeed()); + seedField.addActionListener(event -> applySeed()); + seedRow.add(seedField, BorderLayout.CENTER); + seedRow.add(applySeed, BorderLayout.EAST); + controls.add(seedRow); + } + private static JButton createButton(String text) { JButton button = new JButton(text); button.setBackground(FIELD_BACKGROUND); @@ -423,6 +455,9 @@ public final class NoiseExplorerGUI extends JPanel { } private List buildFixedSources() { + if (mode == ExplorerMode.PALETTES) { + return List.of(); + } List sources = new ArrayList<>(); if (customFactory != null && customName != null) { sources.add(new SourceItem( @@ -431,7 +466,7 @@ public final class NoiseExplorerGUI extends JPanel { customName, NoiseCategory.CUSTOM, true, - customFactory::apply + (sourceSeed, depth) -> LoadedSource.of(customFactory.apply(sourceSeed)) )); } for (NoiseStyle style : NoiseStyle.values()) { @@ -441,9 +476,9 @@ public final class NoiseExplorerGUI extends JPanel { style.name(), categorize(style), true, - sourceSeed -> { + (sourceSeed, depth) -> { CNG noise = style.create(new RNG(sourceSeed)); - return (x, z) -> noise.noise(x, z); + return LoadedSource.of((x, z) -> noise.noise(x, z)); } )); } @@ -489,11 +524,17 @@ public final class NoiseExplorerGUI extends JPanel { return (x, z) -> selectedGenerator.getHeight(x, z, mixedSeed); } - private static List loadPackSources(Engine engine) { + private List loadPackSources(Engine engine) { if (engine == null) { return List.of(); } - IrisData data = engine.getData(); + return switch (mode) { + case NOISE -> loadPackGeneratorSources(engine.getData()); + case PALETTES -> loadPackPaletteSources(engine); + }; + } + + private static List loadPackGeneratorSources(IrisData data) { String[] keys = data.getGeneratorLoader().getPossibleKeys(); if (keys == null || keys.length == 0) { return List.of(); @@ -507,19 +548,67 @@ public final class NoiseExplorerGUI extends JPanel { key, NoiseCategory.PACK_GENERATORS, true, - sourceSeed -> { + (sourceSeed, depth) -> { IrisGenerator generator = data.getGeneratorLoader().load(key); if (generator == null) { throw new IllegalStateException("Pack generator not found: " + key); } long mixedSeed = new RNG(sourceSeed).nextParallelRNG(3245).lmax(); - return (x, z) -> generator.getHeight(x, z, mixedSeed); + return LoadedSource.of((x, z) -> generator.getHeight(x, z, mixedSeed)); } )); } return List.copyOf(sources); } + // Palette layers always use the world's terrain seed: they share their cached noise with the running world. + private static List loadPackPaletteSources(Engine engine) { + IrisData data = engine.getData(); + String[] keys = data.getBiomeLoader().getPossibleKeys(); + if (keys == null || keys.length == 0) { + return List.of(); + } + Arrays.sort(keys); + List sources = new ArrayList<>(); + for (String key : keys) { + IrisBiome biome = data.getBiomeLoader().load(key); + if (biome == null) { + continue; + } + for (int layer = 0; layer < biome.getLayers().size(); layer++) { + int selectedLayer = layer; + sources.add(new SourceItem( + paletteSourceId(key, layer), + "Layer " + layer + " · " + summarizeBlocks(biome.getLayers().get(layer)), + key, + NoiseCategory.PACK_PALETTES, + false, + (sourceSeed, depth) -> { + PaletteLayerPreview preview = PaletteLayerPreview.load(engine, key, selectedLayer, depth); + return new LoadedSource(preview::sample, preview); + } + )); + } + } + return List.copyOf(sources); + } + + private static String paletteSourceId(String biomeKey, int layer) { + return "palette:" + biomeKey + "#" + layer; + } + + private static String summarizeBlocks(IrisBiomePaletteLayer layer) { + List names = layer.getPalette().stream() + .map(block -> block.getBlock().replace("minecraft:", "")) + .distinct() + .toList(); + if (names.size() <= SUMMARIZED_BLOCK_COUNT) { + return String.join(", ", names); + } + return String.join(", ", names.subList(0, SUMMARIZED_BLOCK_COUNT)) + + " +" + (names.size() - SUMMARIZED_BLOCK_COUNT) + " more"; + } + private void applyCatalog(long revision, List sources, Throwable error) { if (disposed || revision != catalogLoadRevision.get()) { return; @@ -529,17 +618,14 @@ public final class NoiseExplorerGUI extends JPanel { if (error != null) { catalogLabel.setForeground(ERROR); catalogLabel.setText("Pack catalog unavailable"); - } else if (sources.isEmpty()) { - catalogLabel.setForeground(SECONDARY_TEXT); - catalogLabel.setText("No active pack generators"); } else { catalogLabel.setForeground(SECONDARY_TEXT); - catalogLabel.setText(sources.size() + " pack generators"); + catalogLabel.setText(catalogSummary(sources)); } boolean reloadSelectedPack = false; if (error == null && selectedSource != null - && selectedSource.category() == NoiseCategory.PACK_GENERATORS) { + && selectedSource.category().isFromPack()) { SourceItem replacement = findSourceById(selectedSource.id(), sources); if (replacement == null) { failRemovedPackSource(selectedSource); @@ -552,6 +638,29 @@ public final class NoiseExplorerGUI extends JPanel { if (reloadSelectedPack) { loadSelectedSource(); } + if (error == null && awaitingInitialPackSource && !sources.isEmpty()) { + awaitingInitialPackSource = false; + selectInitialPackSource(sources); + } + } + + private void selectInitialPackSource(List sources) { + SourceItem requested = initialPackSourceId == null ? null : findSourceById(initialPackSourceId, sources); + SourceItem initial = requested == null ? sources.get(0) : requested; + if (!selectSourceInList(initial.id())) { + selectSource(initial); + } + } + + private String catalogSummary(List sources) { + if (mode == ExplorerMode.NOISE) { + return sources.isEmpty() ? "No active pack generators" : sources.size() + " pack generators"; + } + if (resolveEngine() == null) { + return "Open a studio world to see its palettes"; + } + long biomes = sources.stream().map(SourceItem::rawName).distinct().count(); + return sources.size() + " palette layers in " + biomes + " biomes"; } private void failRemovedPackSource(SourceItem removedSource) { @@ -560,15 +669,15 @@ public final class NoiseExplorerGUI extends JPanel { viewRevision++; renderCoordinator.cancel(viewRevision); selectedSource = null; - sampler = null; + clearSampler(); renderedImage = null; renderResult = null; sourceLoading = false; rendering = false; - errorMessage = "Pack generator removed during hotload: " + removedSource.rawName(); + errorMessage = "Pack source removed during hotload: " + removedSource.text(); selectedLabel.setText("No source selected"); progressLabel.setForeground(ERROR); - progressLabel.setText("Selected pack generator was removed"); + progressLabel.setText("Selected pack source was removed"); sourceList.clearSelection(); repaint(); } @@ -586,6 +695,10 @@ public final class NoiseExplorerGUI extends JPanel { if (matching.isEmpty()) { continue; } + if (category == NoiseCategory.PACK_PALETTES) { + addGroupedByBiome(matching); + continue; + } sourceModel.addElement(new ListItem(categoryLabel(category), null)); for (SourceItem source : matching) { sourceModel.addElement(new ListItem(source.text(), source)); @@ -596,6 +709,17 @@ public final class NoiseExplorerGUI extends JPanel { } } + private void addGroupedByBiome(List paletteSources) { + String currentBiome = null; + for (SourceItem source : paletteSources) { + if (!source.rawName().equals(currentBiome)) { + currentBiome = source.rawName(); + sourceModel.addElement(new ListItem(currentBiome, null)); + } + sourceModel.addElement(new ListItem(source.text(), source)); + } + } + private List matchingSources(NoiseCategory category, String filter) { List matching = new ArrayList<>(); addMatchingSources(matching, fixedSources, category, filter); @@ -633,7 +757,8 @@ public final class NoiseExplorerGUI extends JPanel { return; } selectedSource = source; - selectedLabel.setText(source.text()); + paletteDepth = 0; + selectedLabel.setText(source.category() == NoiseCategory.PACK_PALETTES ? source.rawName() : source.text()); loadSelectedSource(); } @@ -644,6 +769,7 @@ public final class NoiseExplorerGUI extends JPanel { } long revision = sourceLoadRevision.incrementAndGet(); long requestedSeed = seed; + int requestedDepth = paletteDepth; refinementTimer.stop(); viewRevision++; renderCoordinator.cancel(viewRevision); @@ -658,11 +784,11 @@ public final class NoiseExplorerGUI extends JPanel { repaint(); submitLatest(sourceExecutor, () -> { try { - NoiseProvider loadedSampler = source.factory().create(requestedSeed); - if (loadedSampler == null) { + LoadedSource loaded = source.factory().create(requestedSeed, requestedDepth); + if (loaded == null || loaded.sampler() == null) { throw new IllegalStateException("Noise source returned no sampler: " + source.rawName()); } - EventQueue.invokeLater(() -> applyLoadedSource(revision, source, loadedSampler)); + EventQueue.invokeLater(() -> applyLoadedSource(revision, source, loaded)); } catch (Throwable error) { Iris.reportError(error); EventQueue.invokeLater(() -> applySourceError(revision, source, error)); @@ -670,11 +796,13 @@ public final class NoiseExplorerGUI extends JPanel { }); } - private void applyLoadedSource(long revision, SourceItem source, NoiseProvider loadedSampler) { + private void applyLoadedSource(long revision, SourceItem source, LoadedSource loaded) { if (disposed || revision != sourceLoadRevision.get() || selectedSource != source) { return; } - sampler = loadedSampler; + sampler = loaded.sampler(); + palettePreview = loaded.palettePreview(); + showPaletteInspector(); sourceLoading = false; errorMessage = null; renderedImage = null; @@ -688,7 +816,7 @@ public final class NoiseExplorerGUI extends JPanel { if (disposed || revision != sourceLoadRevision.get() || selectedSource != source) { return; } - sampler = null; + clearSampler(); sourceLoading = false; rendering = false; errorMessage = readableError(error); @@ -697,6 +825,28 @@ public final class NoiseExplorerGUI extends JPanel { repaint(); } + private void clearSampler() { + sampler = null; + palettePreview = null; + showPaletteInspector(); + } + + private void showPaletteInspector() { + if (palettePreview == null) { + paletteInspector.hideInspector(); + } else { + paletteInspector.showPreview(palettePreview); + } + if (paletteInspector.getParent() != null) { + paletteInspector.getParent().revalidate(); + } + } + + private NoiseColorScale activeColorScale() { + PaletteLayerPreview preview = palettePreview; + return preview == null ? palette : new PaletteLayerScale(paletteInspector.selectedView(), preview, palette); + } + private void applySeed() { String rawSeed = seedField.getText().trim(); try { @@ -767,7 +917,7 @@ public final class NoiseExplorerGUI extends JPanel { viewRevision, sampler, viewport, - palette, + activeColorScale(), getWidth(), renderHeight(), sampleStep @@ -935,6 +1085,7 @@ public final class NoiseExplorerGUI extends JPanel { } private void drawLegend(Graphics2D canvas, int width) { + NoiseColorScale colorScale = activeColorScale(); int legendWidth = 190; int legendHeight = 40; int legendX = Math.max(10, width - legendWidth - 12); @@ -946,16 +1097,16 @@ public final class NoiseExplorerGUI extends JPanel { int gradientWidth = legendWidth - 18; for (int x = 0; x < gradientWidth; x++) { double normalized = x / (double) Math.max(1, gradientWidth - 1); - canvas.setColor(palette.displayColorNormalized(normalized)); + canvas.setColor(colorScale.displayColorNormalized(normalized)); canvas.drawLine(gradientX + x, gradientY, gradientX + x, gradientY + 8); } canvas.setFont(LEGEND_FONT); canvas.setColor(PRIMARY_TEXT); - canvas.drawString(formatNumber(palette.minimum()), gradientX, legendY + 31); - String maximum = formatNumber(palette.maximum()); + canvas.drawString(formatNumber(colorScale.minimum()), gradientX, legendY + 31); + String maximum = formatNumber(colorScale.maximum()); int maximumWidth = canvas.getFontMetrics().stringWidth(maximum); canvas.drawString(maximum, gradientX + gradientWidth - maximumWidth, legendY + 31); - String label = palette.label(); + String label = colorScale.label(); int labelWidth = canvas.getFontMetrics().stringWidth(label); canvas.drawString(label, legendX + ((legendWidth - labelWidth) / 2), legendY + 31); } @@ -1010,12 +1161,24 @@ public final class NoiseExplorerGUI extends JPanel { if (renderResult == null) { return coordinates + (catalogLoading ? " · loading catalog" : ""); } + PaletteLayerPreview preview = palettePreview; + if (preview != null) { + return coordinates + " · " + describePaletteAt(preview, worldX, worldZ); + } NoiseRenderCoordinator.Result result = renderResult; long clipped = result.underflow() + result.overflow(); return coordinates + " · range " + formatNumber(result.minimum()) + ".." + formatNumber(result.maximum()) + " · clipped " + clipped + " invalid " + result.invalid(); } + private static String describePaletteAt(PaletteLayerPreview preview, double worldX, double worldZ) { + double noise = preview.sample(worldX, worldZ); + if (Double.isNaN(noise)) { + return "layer doesn't reach this depth here"; + } + return preview.entryForNoise(noise).block() + " · noise " + formatNumber(noise); + } + private int renderHeight() { return Math.max(1, getHeight() - STATUS_HEIGHT); } @@ -1103,6 +1266,7 @@ public final class NoiseExplorerGUI extends JPanel { return switch (category) { case CUSTOM -> "Custom"; case PACK_GENERATORS -> "Pack Generators"; + case PACK_PALETTES -> "Pack Palettes"; case SIMPLEX -> "Simplex"; case PERLIN -> "Perlin"; case CELLULAR -> "Cellular"; @@ -1131,7 +1295,13 @@ public final class NoiseExplorerGUI extends JPanel { @FunctionalInterface private interface SourceFactory { - NoiseProvider create(long seed); + LoadedSource create(long seed, int paletteDepth); + } + + private record LoadedSource(NoiseProvider sampler, PaletteLayerPreview palettePreview) { + private static LoadedSource of(NoiseProvider sampler) { + return new LoadedSource(sampler, null); + } } private record SourceItem(String id, String text, String rawName, NoiseCategory category, boolean usesSeed, @@ -1141,6 +1311,22 @@ public final class NoiseExplorerGUI extends JPanel { private record ListItem(String text, SourceItem source) { } + private final class PaletteInspectorListener implements PaletteInspectorPanel.Listener { + @Override + public void onViewChanged(PaletteLayerScale.View view) { + requestPreview(); + } + + @Override + public void onDepthChanged(int depth) { + if (depth == paletteDepth) { + return; + } + paletteDepth = depth; + loadSelectedSource(); + } + } + private final class RenderListener implements NoiseRenderCoordinator.Listener { @Override public void onRenderStarted(NoiseRenderCoordinator.Request request) { @@ -1217,9 +1403,15 @@ public final class NoiseExplorerGUI extends JPanel { } } + private enum ExplorerMode { + NOISE, + PALETTES + } + private enum NoiseCategory { CUSTOM, PACK_GENERATORS, + PACK_PALETTES, SIMPLEX, PERLIN, CELLULAR, @@ -1234,6 +1426,10 @@ public final class NoiseExplorerGUI extends JPanel { NOWHERE, SIERPINSKI, UTILITY, - OTHER + OTHER; + + private boolean isFromPack() { + return this == PACK_GENERATORS || this == PACK_PALETTES; + } } } diff --git a/core/src/main/java/com/volmit/iris/core/gui/NoisePalette.java b/core/src/main/java/com/volmit/iris/core/gui/NoisePalette.java index 1783b1b68..f976b4d5d 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/NoisePalette.java +++ b/core/src/main/java/com/volmit/iris/core/gui/NoisePalette.java @@ -20,7 +20,7 @@ package com.volmit.iris.core.gui; import java.awt.Color; -enum NoisePalette { +enum NoisePalette implements NoiseColorScale { TERRAIN("Terrain", 0D, 1D, new int[]{0x071A2F, 0x155E75, 0x2A9D8F, 0xE9C46A, 0xF4F1DE}), SIGNED("Signed", -1D, 1D, new int[]{0x173B66, 0x4F86C6, 0xE7EDF3, 0xE89555, 0x9D3B35}), GRAYSCALE("Grayscale", 0D, 1D, new int[]{0x050505, 0xFFFFFF}); @@ -48,7 +48,8 @@ enum NoisePalette { return colorFinite(value); } - int colorFinite(double value) { + @Override + public int colorFinite(double value) { double normalized = (value - minimum) / (maximum - minimum); return colorNormalized(normalized); } @@ -58,20 +59,29 @@ enum NoisePalette { return lookup[(int) Math.round(clipped * (lookup.length - 1))]; } - Color displayColorNormalized(double normalized) { + @Override + public int colorInvalid() { + return INVALID_COLOR; + } + + @Override + public Color displayColorNormalized(double normalized) { double clipped = Math.max(0D, Math.min(1D, normalized)); return displayColors[(int) Math.round(clipped * (displayColors.length - 1))]; } - String label() { + @Override + public String label() { return label; } - double minimum() { + @Override + public double minimum() { return minimum; } - double maximum() { + @Override + public double maximum() { return maximum; } diff --git a/core/src/main/java/com/volmit/iris/core/gui/NoiseRenderCoordinator.java b/core/src/main/java/com/volmit/iris/core/gui/NoiseRenderCoordinator.java index 115d2b502..7f6148539 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/NoiseRenderCoordinator.java +++ b/core/src/main/java/com/volmit/iris/core/gui/NoiseRenderCoordinator.java @@ -222,9 +222,9 @@ final class NoiseRenderCoordinator implements AutoCloseable { double sampleOffset = request.sampleStep() * 0.5D; double startWorldX = request.viewport().worldX(sampleOffset, request.width()); double worldStep = request.viewport().blocksPerPixel() * request.sampleStep(); - NoisePalette palette = request.palette(); - double paletteMinimum = palette.minimum(); - double paletteMaximum = palette.maximum(); + NoiseColorScale colorScale = request.colorScale(); + double paletteMinimum = colorScale.minimum(); + double paletteMaximum = colorScale.maximum(); for (int y = fromY; y < toY; y++) { double screenY = (y * (double) request.sampleStep()) + sampleOffset; double worldZ = request.viewport().worldZ(screenY, request.height()); @@ -238,10 +238,10 @@ final class NoiseRenderCoordinator implements AutoCloseable { double value = request.sampler().noise(worldX, worldZ); boolean center = centerRow && x == outputWidth / 2; if (Double.isFinite(value)) { - pixels[pixelIndex++] = palette.colorFinite(value); + pixels[pixelIndex++] = colorScale.colorFinite(value); stats.acceptFinite(value, center, paletteMinimum, paletteMaximum); } else { - pixels[pixelIndex++] = NoisePalette.INVALID_COLOR; + pixels[pixelIndex++] = colorScale.colorInvalid(); stats.acceptInvalid(value, center); } worldX += worldStep; @@ -315,12 +315,12 @@ final class NoiseRenderCoordinator implements AutoCloseable { void onRenderFailed(Request request, Throwable error); } - record Request(long revision, NoiseProvider sampler, NoiseViewport viewport, NoisePalette palette, + record Request(long revision, NoiseProvider sampler, NoiseViewport viewport, NoiseColorScale colorScale, int width, int height, int sampleStep) { Request { Objects.requireNonNull(sampler, "sampler"); Objects.requireNonNull(viewport, "viewport"); - Objects.requireNonNull(palette, "palette"); + Objects.requireNonNull(colorScale, "colorScale"); if (revision < 0L || width < 1 || height < 1 || sampleStep < 1) { throw new IllegalArgumentException("Invalid noise render request"); } diff --git a/core/src/main/java/com/volmit/iris/core/gui/PaletteInspectorPanel.java b/core/src/main/java/com/volmit/iris/core/gui/PaletteInspectorPanel.java new file mode 100644 index 000000000..d63b81dff --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/PaletteInspectorPanel.java @@ -0,0 +1,399 @@ +/* + * 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.BorderFactory; +import javax.swing.JComboBox; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JSlider; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.GridLayout; +import java.awt.RenderingHints; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import static com.volmit.iris.core.gui.NoiseExplorerGUI.ACCENT; +import static com.volmit.iris.core.gui.NoiseExplorerGUI.BORDER; +import static com.volmit.iris.core.gui.NoiseExplorerGUI.FIELD_BACKGROUND; +import static com.volmit.iris.core.gui.NoiseExplorerGUI.HEADER_FONT; +import static com.volmit.iris.core.gui.NoiseExplorerGUI.ITEM_FONT; +import static com.volmit.iris.core.gui.NoiseExplorerGUI.LEGEND_FONT; +import static com.volmit.iris.core.gui.NoiseExplorerGUI.PANEL_BACKGROUND; +import static com.volmit.iris.core.gui.NoiseExplorerGUI.PRIMARY_TEXT; +import static com.volmit.iris.core.gui.NoiseExplorerGUI.SECONDARY_TEXT; +import static com.volmit.iris.core.gui.NoiseExplorerGUI.SIDEBAR_BACKGROUND; + +final class PaletteInspectorPanel extends JPanel { + private static final long serialVersionUID = 4473102938518712001L; + private static final int PANEL_WIDTH = 310; + private static final Color WARNING = new Color(255, 196, 92); + private static final Color HISTOGRAM_BAR = new Color(151, 160, 178, 170); + private static final Color BAND_DIVIDER = new Color(255, 255, 255, 70); + + private final Listener listener; + private final JLabel titleLabel = new JLabel(); + private final JLabel detailLabel = new JLabel(); + private final JComboBox viewPicker = new JComboBox<>(PaletteLayerScale.View.values()); + private final JLabel depthLabel = new JLabel(); + private final JSlider depthSlider = new JSlider(0, 0, 0); + private final StatisticsView statisticsView = new StatisticsView(); + private boolean updatingControls; + + PaletteInspectorPanel(Listener listener) { + super(new BorderLayout()); + this.listener = listener; + setPreferredSize(new Dimension(PANEL_WIDTH, 0)); + setBackground(SIDEBAR_BACKGROUND); + setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, BORDER)); + add(buildControls(), BorderLayout.NORTH); + JScrollPane statisticsScroll = new JScrollPane(statisticsView); + statisticsScroll.setBorder(BorderFactory.createEmptyBorder()); + statisticsScroll.getVerticalScrollBar().setUnitIncrement(18); + add(statisticsScroll, BorderLayout.CENTER); + setVisible(false); + } + + void showPreview(PaletteLayerPreview preview) { + updatingControls = true; + try { + titleLabel.setText(preview.biomeKey() + " · layer " + preview.layer()); + detailLabel.setText(thicknessText(preview) + " · zoom " + formatZoom(preview.zoom()) + + " (×" + formatZoom(preview.zoom() * preview.zoom()) + " effective)"); + int deepestDepth = Math.max(0, preview.maxThickness() - 1); + depthSlider.setMaximum(deepestDepth); + depthSlider.setValue(preview.depth()); + depthSlider.setEnabled(deepestDepth > 0); + depthLabel.setText(depthText(preview.depth(), deepestDepth)); + } finally { + updatingControls = false; + } + statisticsView.show(preview); + setVisible(true); + } + + void hideInspector() { + setVisible(false); + } + + PaletteLayerScale.View selectedView() { + return (PaletteLayerScale.View) viewPicker.getSelectedItem(); + } + + private JPanel buildControls() { + JPanel controls = new JPanel(new GridLayout(0, 1, 0, 6)); + controls.setBackground(PANEL_BACKGROUND); + controls.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(0, 0, 1, 0, BORDER), + BorderFactory.createEmptyBorder(12, 14, 12, 14) + )); + titleLabel.setForeground(PRIMARY_TEXT); + titleLabel.setFont(ITEM_FONT.deriveFont(Font.BOLD, 14F)); + detailLabel.setForeground(SECONDARY_TEXT); + detailLabel.setFont(ITEM_FONT); + controls.add(titleLabel); + controls.add(detailLabel); + + controls.add(sectionLabel("View")); + viewPicker.setBackground(FIELD_BACKGROUND); + viewPicker.setForeground(PRIMARY_TEXT); + viewPicker.addActionListener(event -> listener.onViewChanged(selectedView())); + controls.add(viewPicker); + + depthLabel.setForeground(SECONDARY_TEXT); + depthLabel.setFont(HEADER_FONT); + controls.add(depthLabel); + depthSlider.setOpaque(false); + depthSlider.addChangeListener(event -> { + if (!updatingControls && !depthSlider.getValueIsAdjusting()) { + listener.onDepthChanged(depthSlider.getValue()); + } + }); + depthSlider.addChangeListener(event -> depthLabel.setText(depthText(depthSlider.getValue(), depthSlider.getMaximum()))); + controls.add(depthSlider); + return controls; + } + + private static JLabel sectionLabel(String text) { + JLabel label = new JLabel(text); + label.setForeground(SECONDARY_TEXT); + label.setFont(HEADER_FONT); + return label; + } + + private static String thicknessText(PaletteLayerPreview preview) { + if (preview.minThickness() == preview.maxThickness()) { + return preview.maxThickness() + (preview.maxThickness() == 1 ? " block thick" : " blocks thick"); + } + return preview.minThickness() + "–" + preview.maxThickness() + " blocks thick"; + } + + private static String depthText(int depth, int deepestDepth) { + if (deepestDepth == 0) { + return "Depth: top block only"; + } + return "Depth " + depth + (depth == 0 ? " (top)" : "") + " of " + deepestDepth; + } + + private static String formatZoom(double zoom) { + return zoom == Math.rint(zoom) ? Long.toString((long) zoom) : String.format(Locale.ROOT, "%.2f", zoom); + } + + private static String percent(double share) { + if (share > 0D && share < 0.001D) { + return "<0.1%"; + } + return String.format(Locale.ROOT, "%.1f%%", share * 100D); + } + + interface Listener { + void onViewChanged(PaletteLayerScale.View view); + + void onDepthChanged(int depth); + } + + private static final class StatisticsView extends JComponent { + private static final long serialVersionUID = -2318840125766150871L; + private static final int MARGIN = 14; + private static final int HISTOGRAM_HEIGHT = 84; + private static final int BAND_HEIGHT = 12; + private static final int ROW_HEIGHT = 20; + private static final int SWATCH_SIZE = 10; + private static final int WEIGHT_COLUMN_WIDTH = 48; + private static final int ACTUAL_COLUMN_WIDTH = 70; + private static final int NOTE_LINE_HEIGHT = 15; + + private PaletteLayerPreview preview; + + private StatisticsView() { + setOpaque(true); + setBackground(SIDEBAR_BACKGROUND); + } + + private void show(PaletteLayerPreview nextPreview) { + preview = nextPreview; + revalidate(); + repaint(); + } + + @Override + public Dimension getPreferredSize() { + if (preview == null) { + return new Dimension(PANEL_WIDTH - 20, 0); + } + int width = Math.max(getWidth(), PANEL_WIDTH - 20) - MARGIN * 2; + FontMetrics noteMetrics = getFontMetrics(LEGEND_FONT); + int noteLines = 0; + for (String note : notes(preview)) { + noteLines += wrap(note, noteMetrics, width).size() + 1; + } + int height = MARGIN + 18 + HISTOGRAM_HEIGHT + BAND_HEIGHT + 30 + + 22 + ROW_HEIGHT * (preview.entries().size() + 1) + + 16 + noteLines * NOTE_LINE_HEIGHT + MARGIN; + return new Dimension(PANEL_WIDTH - 20, height); + } + + @Override + protected void paintComponent(Graphics graphics) { + Graphics2D canvas = (Graphics2D) graphics.create(); + try { + canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + canvas.setColor(SIDEBAR_BACKGROUND); + canvas.fillRect(0, 0, getWidth(), getHeight()); + if (preview == null) { + return; + } + int width = getWidth() - MARGIN * 2; + int y = drawValueSpread(canvas, MARGIN, MARGIN, width); + y = drawBlockTable(canvas, MARGIN, y + 22, width); + drawNotes(canvas, MARGIN, y + 16, width); + } finally { + canvas.dispose(); + } + } + + private int drawValueSpread(Graphics2D canvas, int x, int y, int width) { + drawSectionTitle(canvas, "NOISE VALUES THIS LAYER PRODUCES", x, y + 10); + int histogramTop = y + 18; + long[] histogram = preview.histogram(); + long peak = 1L; + for (long count : histogram) { + peak = Math.max(peak, count); + } + canvas.setColor(HISTOGRAM_BAR); + for (int bin = 0; bin < histogram.length; bin++) { + int barLeft = x + (bin * width) / histogram.length; + int barRight = x + ((bin + 1) * width) / histogram.length; + int barHeight = (int) Math.round(HISTOGRAM_HEIGHT * (histogram[bin] / (double) peak)); + canvas.fillRect(barLeft, histogramTop + HISTOGRAM_HEIGHT - barHeight, Math.max(1, barRight - barLeft - 1), barHeight); + } + + int bandTop = histogramTop + HISTOGRAM_HEIGHT + 2; + for (PaletteLayerPreview.Entry entry : preview.entries()) { + if (Double.isNaN(entry.lowestNoise())) { + continue; + } + int bandLeft = x + (int) Math.round(entry.lowestNoise() * width); + int bandRight = x + (int) Math.round(entry.highestNoise() * width); + canvas.setColor(new Color(entry.color())); + canvas.fillRect(bandLeft, bandTop, Math.max(1, bandRight - bandLeft), BAND_HEIGHT); + if (entry.highestNoise() < 1D) { + canvas.setColor(BAND_DIVIDER); + canvas.drawLine(bandRight, histogramTop, bandRight, bandTop + BAND_HEIGHT - 1); + } + } + + canvas.setFont(LEGEND_FONT); + canvas.setColor(SECONDARY_TEXT); + FontMetrics metrics = canvas.getFontMetrics(); + int labelY = bandTop + BAND_HEIGHT + 13; + canvas.drawString("0", x, labelY); + canvas.drawString("0.5", x + (width - metrics.stringWidth("0.5")) / 2, labelY); + canvas.drawString("1", x + width - metrics.stringWidth("1"), labelY); + return labelY; + } + + private int drawBlockTable(Graphics2D canvas, int x, int y, int width) { + int actualRight = x + width; + int weightRight = actualRight - ACTUAL_COLUMN_WIDTH; + drawSectionTitle(canvas, "BLOCKS", x, y); + drawRightAligned(canvas, "weight", weightRight, y); + drawRightAligned(canvas, "actual", actualRight, y); + + int rowY = y + ROW_HEIGHT; + for (PaletteLayerPreview.Entry entry : preview.entries()) { + canvas.setColor(new Color(entry.color())); + canvas.fillRoundRect(x, rowY - SWATCH_SIZE, SWATCH_SIZE, SWATCH_SIZE, 3, 3); + canvas.setFont(ITEM_FONT); + canvas.setColor(entry.found() ? PRIMARY_TEXT : NoiseExplorerGUI.ERROR); + String name = entry.found() ? shortBlockName(entry.block()) : shortBlockName(entry.block()) + " (not found)"; + int nameLeft = x + SWATCH_SIZE + 7; + canvas.drawString(fit(canvas, name, weightRight - WEIGHT_COLUMN_WIDTH - nameLeft), nameLeft, rowY); + canvas.setColor(SECONDARY_TEXT); + drawRightAligned(canvas, percent(entry.weightShare()), weightRight, rowY); + canvas.setColor(actualColor(entry)); + drawRightAligned(canvas, actualText(entry), actualRight, rowY); + rowY += ROW_HEIGHT; + } + return rowY - ROW_HEIGHT; + } + + private void drawNotes(Graphics2D canvas, int x, int y, int width) { + canvas.setFont(LEGEND_FONT); + canvas.setColor(SECONDARY_TEXT); + FontMetrics metrics = canvas.getFontMetrics(); + int lineY = y; + for (String note : notes(preview)) { + for (String line : wrap(note, metrics, width)) { + canvas.drawString(line, x, lineY); + lineY += NOTE_LINE_HEIGHT; + } + lineY += 4; + } + } + + private static List notes(PaletteLayerPreview preview) { + List notes = new ArrayList<>(); + notes.add("▼ gets under half its weight, ▲ gets over double."); + notes.add(String.format(Locale.ROOT, "Measured on a %d × %d grid spread over %,.0f blocks around 0, 0.", + PaletteLayerPreview.STATISTICS_GRID_SIZE, PaletteLayerPreview.STATISTICS_GRID_SIZE, + PaletteLayerPreview.statisticsAreaBlocks())); + if (preview.absentShare() > 0D) { + notes.add("The layer doesn't reach this depth on " + percent(preview.absentShare()) + + " of the ground. That ground is dark on the map and left out of the shares."); + } + if (preview.outOfRangeShare() > 0D) { + notes.add(percent(preview.outOfRangeShare()) + " of the noise values fall outside 0 to 1." + + " Those spots get the first block."); + } + if (preview.hasSlopeCondition()) { + notes.add("This layer has a slope condition, which the preview ignores."); + } + return notes; + } + + private static String actualText(PaletteLayerPreview.Entry entry) { + if (entry.isFarBelowWeight()) { + return "▼ " + percent(entry.actualShare()); + } + if (entry.isFarAboveWeight()) { + return "▲ " + percent(entry.actualShare()); + } + return percent(entry.actualShare()); + } + + private static Color actualColor(PaletteLayerPreview.Entry entry) { + return entry.isFarBelowWeight() || entry.isFarAboveWeight() ? WARNING : PRIMARY_TEXT; + } + + private static void drawSectionTitle(Graphics2D canvas, String title, int x, int y) { + canvas.setFont(HEADER_FONT); + canvas.setColor(ACCENT); + canvas.drawString(title, x, y); + } + + private static void drawRightAligned(Graphics2D canvas, String text, int right, int y) { + canvas.drawString(text, right - canvas.getFontMetrics().stringWidth(text), y); + } + + private static String shortBlockName(String block) { + return block.startsWith("minecraft:") ? block.substring("minecraft:".length()) : block; + } + + private static String fit(Graphics2D canvas, String text, int maxWidth) { + FontMetrics metrics = canvas.getFontMetrics(); + if (metrics.stringWidth(text) <= maxWidth) { + return text; + } + String shortened = text; + while (shortened.length() > 1 && metrics.stringWidth(shortened + "…") > maxWidth) { + shortened = shortened.substring(0, shortened.length() - 1); + } + return shortened + "…"; + } + + private static List wrap(String text, FontMetrics metrics, int maxWidth) { + List lines = new ArrayList<>(); + StringBuilder line = new StringBuilder(); + for (String word : text.split(" ")) { + String candidate = line.isEmpty() ? word : line + " " + word; + if (metrics.stringWidth(candidate) > maxWidth && !line.isEmpty()) { + lines.add(line.toString()); + line = new StringBuilder(word); + } else { + line = new StringBuilder(candidate); + } + } + if (!line.isEmpty()) { + lines.add(line.toString()); + } + return lines; + } + } +} diff --git a/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerPreview.java b/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerPreview.java new file mode 100644 index 000000000..9a4932d3b --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerPreview.java @@ -0,0 +1,267 @@ +/* + * 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 com.volmit.iris.core.loader.IrisData; +import com.volmit.iris.engine.framework.Engine; +import com.volmit.iris.engine.object.IrisBiome; +import com.volmit.iris.engine.object.IrisBiomePaletteLayer; +import com.volmit.iris.engine.object.IrisBlockData; +import com.volmit.iris.util.collection.KList; +import com.volmit.iris.util.math.RNG; +import org.bukkit.block.data.BlockData; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +final class PaletteLayerPreview { + static final int HISTOGRAM_BINS = 50; + static final int STATISTICS_GRID_SIZE = 64; + static final double STATISTICS_SPACING_BLOCKS = 37D; + private static final int MISSING_BLOCK_COLOR = 0x5A5F6B; + private static final double FAR_FROM_WEIGHT_FACTOR = 2D; + + private final String biomeKey; + private final IrisBiome biome; + private final IrisBiomePaletteLayer paletteLayer; + private final int layer; + private final int depth; + private final RNG random; + private final IrisData data; + private final int[] entryBySlot; + private final int[] colorByEntry; + private final List entries; + private final long[] histogram; + private final double absentShare; + private final double outOfRangeShare; + + private PaletteLayerPreview(String biomeKey, IrisBiome biome, int layer, int depth, RNG random, IrisData data) { + this.biomeKey = biomeKey; + this.biome = biome; + this.paletteLayer = biome.getLayers().get(layer); + this.layer = layer; + this.depth = depth; + this.random = random; + this.data = data; + this.entryBySlot = paletteLayer.getPaletteEntryPerSlot(data).stream().mapToInt(Integer::intValue).toArray(); + if (entryBySlot.length == 0) { + throw new IllegalStateException("No palette block in " + biomeKey + " layer " + layer + " could be found"); + } + this.colorByEntry = resolveEntryColors(); + + Statistics statistics = measure(); + this.histogram = statistics.histogram(); + this.absentShare = statistics.absentShare(); + this.outOfRangeShare = statistics.outOfRangeShare(); + this.entries = describeEntries(statistics.sharesByEntry()); + } + + static PaletteLayerPreview load(Engine engine, String biomeKey, int layer, int depth) { + if (engine == null || engine.isClosed()) { + throw new IllegalStateException("Open a studio world to preview pack palettes"); + } + IrisData data = engine.getData(); + IrisBiome biome = data.getBiomeLoader().load(biomeKey); + if (biome == null || layer >= biome.getLayers().size()) { + throw new IllegalStateException("Palette layer removed during hotload: " + biomeKey + " layer " + layer); + } + RNG terrainRandom = new RNG(engine.getSeedManager().getTerrain()); + return new PaletteLayerPreview(biomeKey, biome, layer, depth, terrainRandom, data); + } + + double sample(double x, double z) { + if (biome.getLayerThickness(layer, x, z, random, data) <= depth) { + return Double.NaN; + } + return biome.getLayerNoise(layer, x, z, depth, random, data); + } + + int colorForNoise(double noise) { + return colorByEntry[entryIndexForNoise(noise)]; + } + + Entry entryForNoise(double noise) { + return entries.get(entryIndexForNoise(noise)); + } + + String biomeKey() { + return biomeKey; + } + + int layer() { + return layer; + } + + int depth() { + return depth; + } + + int minThickness() { + return paletteLayer.getMinHeight(); + } + + int maxThickness() { + return paletteLayer.getMaxHeight(); + } + + double zoom() { + return paletteLayer.getZoom(); + } + + boolean hasSlopeCondition() { + return !paletteLayer.getSlopeCondition().isDefault(); + } + + List entries() { + return entries; + } + + long[] histogram() { + return histogram.clone(); + } + + double absentShare() { + return absentShare; + } + + double outOfRangeShare() { + return outOfRangeShare; + } + + static double statisticsAreaBlocks() { + return STATISTICS_GRID_SIZE * STATISTICS_SPACING_BLOCKS; + } + + private int entryIndexForNoise(double noise) { + return entryBySlot[IrisBiomePaletteLayer.slotForNoise(noise, entryBySlot.length)]; + } + + private int[] resolveEntryColors() { + KList palette = paletteLayer.getPalette(); + List blocks = new ArrayList<>(); + for (IrisBlockData entry : palette) { + BlockData block = entry.getBlockData(data); + if (block != null) { + blocks.add(block); + } + } + Map colorsByBlock = BlockPreviewColors.colorsByBlock(blocks); + int[] colors = new int[palette.size()]; + for (int entry = 0; entry < palette.size(); entry++) { + BlockData block = palette.get(entry).getBlockData(data); + colors[entry] = block == null ? MISSING_BLOCK_COLOR : colorsByBlock.get(block.getAsString(true)); + } + return colors; + } + + private Statistics measure() { + long[] values = new long[HISTOGRAM_BINS]; + long[] samplesByEntry = new long[paletteLayer.getPalette().size()]; + long present = 0; + long absent = 0; + long outOfRange = 0; + double origin = -statisticsAreaBlocks() / 2D; + for (int gridX = 0; gridX < STATISTICS_GRID_SIZE; gridX++) { + for (int gridZ = 0; gridZ < STATISTICS_GRID_SIZE; gridZ++) { + double noise = sample(origin + gridX * STATISTICS_SPACING_BLOCKS, origin + gridZ * STATISTICS_SPACING_BLOCKS); + if (Double.isNaN(noise)) { + absent++; + continue; + } + present++; + if (noise < 0D || noise > 1D) { + outOfRange++; + } + values[Math.clamp((int) (noise * HISTOGRAM_BINS), 0, HISTOGRAM_BINS - 1)]++; + samplesByEntry[entryIndexForNoise(noise)]++; + } + } + double[] sharesByEntry = new double[samplesByEntry.length]; + for (int entry = 0; entry < samplesByEntry.length; entry++) { + sharesByEntry[entry] = present == 0 ? 0D : samplesByEntry[entry] / (double) present; + } + long total = present + absent; + return new Statistics(values, sharesByEntry, absent / (double) total, present == 0 ? 0D : outOfRange / (double) present); + } + + private List describeEntries(double[] sharesByEntry) { + KList palette = paletteLayer.getPalette(); + int slotCount = entryBySlot.length; + List described = new ArrayList<>(palette.size()); + for (int entry = 0; entry < palette.size(); entry++) { + IrisBlockData block = palette.get(entry); + int firstSlot = firstSlotOf(entry); + int lastSlot = lastSlotOf(entry); + boolean placed = firstSlot >= 0; + described.add(new Entry( + block.getBlock(), + block.getWeight(), + block.getBlockData(data) != null, + colorByEntry[entry], + placed ? (lastSlot - firstSlot + 1) / (double) slotCount : 0D, + sharesByEntry[entry], + placed ? slotLowerBound(firstSlot, slotCount) : Double.NaN, + placed ? slotUpperBound(lastSlot, slotCount) : Double.NaN + )); + } + return List.copyOf(described); + } + + private int firstSlotOf(int entry) { + for (int slot = 0; slot < entryBySlot.length; slot++) { + if (entryBySlot[slot] == entry) { + return slot; + } + } + return -1; + } + + private int lastSlotOf(int entry) { + for (int slot = entryBySlot.length - 1; slot >= 0; slot--) { + if (entryBySlot[slot] == entry) { + return slot; + } + } + return -1; + } + + // Slots are picked by rounding, so the first and last slot only own half a step of the value range. + private static double slotLowerBound(int slot, int slotCount) { + return slot == 0 ? 0D : (slot - 0.5D) / (slotCount - 1); + } + + private static double slotUpperBound(int slot, int slotCount) { + return slot == slotCount - 1 ? 1D : (slot + 0.5D) / (slotCount - 1); + } + + record Entry(String block, int weight, boolean found, int color, double weightShare, double actualShare, + double lowestNoise, double highestNoise) { + boolean isFarBelowWeight() { + return found && actualShare < weightShare / FAR_FROM_WEIGHT_FACTOR; + } + + boolean isFarAboveWeight() { + return found && actualShare > weightShare * FAR_FROM_WEIGHT_FACTOR; + } + } + + private record Statistics(long[] histogram, double[] sharesByEntry, double absentShare, double outOfRangeShare) { + } +} diff --git a/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerScale.java b/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerScale.java new file mode 100644 index 000000000..53aec82a2 --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerScale.java @@ -0,0 +1,91 @@ +/* + * 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.Color; + +final class PaletteLayerScale implements NoiseColorScale { + static final int ABSENT_COLOR = 0x161A24; + private static final double OVERLAY_DARKEST_SHADE = 0.3D; + + private final View view; + private final PaletteLayerPreview preview; + private final NoisePalette noisePalette; + + PaletteLayerScale(View view, PaletteLayerPreview preview, NoisePalette noisePalette) { + this.view = view; + this.preview = preview; + this.noisePalette = noisePalette; + } + + @Override + public int colorFinite(double value) { + return switch (view) { + case BLOCKS -> preview.colorForNoise(value); + case NOISE -> noisePalette.colorFinite(value); + case BLOCKS_SHADED_BY_NOISE -> BlockPreviewColors.shade(preview.colorForNoise(value), overlayShade(value)); + }; + } + + @Override + public int colorInvalid() { + return ABSENT_COLOR; + } + + @Override + public Color displayColorNormalized(double normalized) { + return new Color(colorFinite(minimum() + normalized * (maximum() - minimum()))); + } + + @Override + public String label() { + return view == View.NOISE ? noisePalette.label() : view.toString(); + } + + @Override + public double minimum() { + return view == View.NOISE ? noisePalette.minimum() : 0D; + } + + @Override + public double maximum() { + return view == View.NOISE ? noisePalette.maximum() : 1D; + } + + private static double overlayShade(double value) { + return OVERLAY_DARKEST_SHADE + (1D - OVERLAY_DARKEST_SHADE) * Math.clamp(value, 0D, 1D); + } + + enum View { + BLOCKS("Blocks"), + NOISE("Noise"), + BLOCKS_SHADED_BY_NOISE("Blocks + noise"); + + private final String label; + + View(String label) { + this.label = label; + } + + @Override + public String toString() { + return label; + } + } +} diff --git a/core/src/main/java/com/volmit/iris/engine/object/IrisBiome.java b/core/src/main/java/com/volmit/iris/engine/object/IrisBiome.java index 42caee79e..9ac7ba1bd 100644 --- a/core/src/main/java/com/volmit/iris/engine/object/IrisBiome.java +++ b/core/src/main/java/com/volmit/iris/engine/object/IrisBiome.java @@ -314,8 +314,7 @@ public class IrisBiome extends IrisRegistrant implements IRare { } for (int i = 0; i < layers.size(); i++) { - CNG hgen = getLayerHeightGenerators(random, rdata).get(i); - double d = hgen.fit(layers.get(i).getMinHeight(), layers.get(i).getMaxHeight(), wx / layers.get(i).getZoom(), wz / layers.get(i).getZoom()); + double d = getLayerThickness(i, wx, wz, random, rdata); IrisSlopeClip sc = getLayers().get(i).getSlopeCondition(); @@ -335,7 +334,7 @@ public class IrisBiome extends IrisRegistrant implements IRare { } try { - data.add(getLayers().get(i).get(random.nextParallelRNG(i + j), (wx + j) / layers.get(i).getZoom(), j, (wz - j) / layers.get(i).getZoom(), rdata)); + data.add(getLayerBlock(i, wx, wz, j, random, rdata)); } catch (Throwable e) { Iris.reportError(e); e.printStackTrace(); @@ -360,6 +359,25 @@ public class IrisBiome extends IrisRegistrant implements IRare { return data; } + public int getLayerThickness(int layer, double wx, double wz, RNG random, IrisData rdata) { + IrisBiomePaletteLayer paletteLayer = layers.get(layer); + CNG thicknessGenerator = getLayerHeightGenerators(random, rdata).get(layer); + return thicknessGenerator.fit(paletteLayer.getMinHeight(), paletteLayer.getMaxHeight(), wx / paletteLayer.getZoom(), wz / paletteLayer.getZoom()); + } + + public BlockData getLayerBlock(int layer, double wx, double wz, int depth, RNG random, IrisData rdata) { + return layers.get(layer).getAtDepth(layerRandom(layer, random), wx, wz, depth, rdata); + } + + public double getLayerNoise(int layer, double wx, double wz, int depth, RNG random, IrisData rdata) { + return layers.get(layer).getNoiseAtDepth(layerRandom(layer, random), wx, wz, depth, rdata); + } + + // Only the first call's random seeds the layer's cached generator, and generation always makes that call at depth 0. + private static RNG layerRandom(int layer, RNG random) { + return random.nextParallelRNG(layer); + } + public KList generateCeilingLayers(IrisDimension dim, double wx, double wz, RNG random, int maxDepth, int height, IrisData rdata, IrisComplex complex) { KList data = new KList<>(); @@ -415,8 +433,7 @@ public class IrisBiome extends IrisRegistrant implements IRare { } for (int i = 0; i < layers.size(); i++) { - CNG hgen = getLayerHeightGenerators(random, rdata).get(i); - double d = hgen.fit(layers.get(i).getMinHeight(), layers.get(i).getMaxHeight(), wx / layers.get(i).getZoom(), wz / layers.get(i).getZoom()); + double d = getLayerThickness(i, wx, wz, random, rdata); IrisSlopeClip sc = getLayers().get(i).getSlopeCondition(); @@ -432,7 +449,7 @@ public class IrisBiome extends IrisRegistrant implements IRare { for (int j = 0; j < d; j++) { try { - data.add(getLayers().get(i).get(random.nextParallelRNG(i + j), (wx + j) / layers.get(i).getZoom(), j, (wz - j) / layers.get(i).getZoom(), rdata)); + data.add(getLayerBlock(i, wx, wz, j, random, rdata)); } catch (Throwable e) { Iris.reportError(e); e.printStackTrace(); diff --git a/core/src/main/java/com/volmit/iris/engine/object/IrisBiomePaletteLayer.java b/core/src/main/java/com/volmit/iris/engine/object/IrisBiomePaletteLayer.java index c2fa1b59f..334e63cfd 100644 --- a/core/src/main/java/com/volmit/iris/engine/object/IrisBiomePaletteLayer.java +++ b/core/src/main/java/com/volmit/iris/engine/object/IrisBiomePaletteLayer.java @@ -22,6 +22,7 @@ import com.volmit.iris.core.loader.IrisData; import com.volmit.iris.engine.data.cache.AtomicCache; import com.volmit.iris.engine.object.annotations.*; import com.volmit.iris.util.collection.KList; +import com.volmit.iris.util.interpolation.IrisInterpolation; import com.volmit.iris.util.math.RNG; import com.volmit.iris.util.noise.CNG; import lombok.AllArgsConstructor; @@ -69,15 +70,37 @@ public class IrisBiomePaletteLayer { } public BlockData get(RNG rng, double x, double y, double z, IrisData data) { - if (getBlockData(data).isEmpty()) { + KList blocks = getBlockData(data); + if (blocks.isEmpty()) { return null; } - if (getBlockData(data).size() == 1) { - return getBlockData(data).get(0); + if (blocks.size() == 1) { + return blocks.get(0); } - return getLayerGenerator(rng, data).fit(getBlockData(data), x / zoom, y / zoom, z / zoom); + return blocks.get(slotForNoise(getLayerGenerator(rng, data).noise(x / zoom, y / zoom, z / zoom), blocks.size())); + } + + public BlockData getAtDepth(RNG rng, double wx, double wz, int depth, IrisData data) { + return get(rng, depthShiftedX(wx, depth), depth, depthShiftedZ(wz, depth), data); + } + + public double getNoiseAtDepth(RNG rng, double wx, double wz, int depth, IrisData data) { + return getLayerGenerator(rng, data).noise(depthShiftedX(wx, depth) / zoom, depth / zoom, depthShiftedZ(wz, depth) / zoom); + } + + public static int slotForNoise(double noise, int slotCount) { + int slot = (int) Math.round(IrisInterpolation.lerp(0, slotCount - 1, noise)); + return slot >= 0 && slot < slotCount ? slot : 0; + } + + private double depthShiftedX(double wx, int depth) { + return (wx + depth) / zoom; + } + + private double depthShiftedZ(double wz, int depth) { + return (wz - depth) / zoom; } public CNG getLayerGenerator(RNG rng, IrisData data) { @@ -98,19 +121,30 @@ public class IrisBiomePaletteLayer { return blockData.aquire(() -> { KList blockData = new KList<>(); - for (IrisBlockData ix : palette) { - BlockData bx = ix.getBlockData(data); - if (bx != null) { - for (int i = 0; i < ix.getWeight(); i++) { - blockData.add(bx); - } - } + for (int entry : getPaletteEntryPerSlot(data)) { + blockData.add(palette.get(entry).getBlockData(data)); } return blockData; }); } + public KList getPaletteEntryPerSlot(IrisData data) { + KList entries = new KList<>(); + for (int entry = 0; entry < palette.size(); entry++) { + IrisBlockData block = palette.get(entry); + if (block.getBlockData(data) == null) { + continue; + } + + for (int i = 0; i < block.getWeight(); i++) { + entries.add(entry); + } + } + + return entries; + } + public IrisBiomePaletteLayer zero() { palette.clear(); return this;