From 84ffce263aa771c20b885563d5554758e670fe08 Mon Sep 17 00:00:00 2001 From: piexel Date: Mon, 19 Oct 2026 17:21:01 +0200 Subject: [PATCH] Show decorator sprites and list snippets in the palette explorer Decorators now draw their plant sprite on each block instead of its average colour, which only suits stacked ground layers. The Overview tab lists every decorator and block palette snippet with how often biomes use it. Picking one opens it where the first biome places it; its rows open each other use. Snippet files no biome uses yet are listed too. Snippet names keep their folders, so two snippets with the same file name in different folders stay apart. Model used: Claude Opus 5.5 --- .../volmit/iris/core/gui/BiomePalette.java | 53 ++++++++++++++++--- .../volmit/iris/core/gui/BlockTextures.java | 28 ++++++++-- .../volmit/iris/core/gui/PaletteCatalog.java | 50 +++++++++++++++-- .../iris/core/gui/PaletteLayerPreview.java | 7 ++- .../iris/core/gui/PaletteNavigator.java | 48 ++++++++++++++++- 5 files changed, 166 insertions(+), 20 deletions(-) diff --git a/core/src/main/java/com/volmit/iris/core/gui/BiomePalette.java b/core/src/main/java/com/volmit/iris/core/gui/BiomePalette.java index 8c0b95929..329dc6ce1 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/BiomePalette.java +++ b/core/src/main/java/com/volmit/iris/core/gui/BiomePalette.java @@ -35,27 +35,34 @@ import java.util.Locale; record BiomePalette(String biomeKey, Kind kind, int index) { private static final String ID_PREFIX = "palette:"; private static final char ID_SEPARATOR = '#'; + private static final String SNIPPET_PREFIX = "snippet/"; enum Kind { - LAYER("Layer", "layers", true, true), - SEA_LAYER("Sea layer", "seaLayers", true, true), - CAVE_CEILING_LAYER("Cave ceiling layer", "caveCeilingLayers", true, true), - WALL("Wall", "wall", false, false), - SLAB("Slab", "slab", false, false), - DECORATOR("Decorator", "decorators", false, true); + LAYER("Layer", "layers", SnippetFolder.BLOCK_PALETTES, true, true), + SEA_LAYER("Sea layer", "seaLayers", SnippetFolder.BLOCK_PALETTES, true, true), + CAVE_CEILING_LAYER("Cave ceiling layer", "caveCeilingLayers", SnippetFolder.BLOCK_PALETTES, true, true), + WALL("Wall", "wall", SnippetFolder.BLOCK_PALETTES, false, false), + SLAB("Slab", "slab", SnippetFolder.BLOCK_PALETTES, false, false), + DECORATOR("Decorator", "decorators", SnippetFolder.DECORATORS, false, true); private final String label; private final String jsonKey; + private final SnippetFolder snippetFolder; private final boolean stacked; private final boolean numbered; - Kind(String label, String jsonKey, boolean stacked, boolean numbered) { + Kind(String label, String jsonKey, SnippetFolder snippetFolder, boolean stacked, boolean numbered) { this.label = label; this.jsonKey = jsonKey; + this.snippetFolder = snippetFolder; this.stacked = stacked; this.numbered = numbered; } + SnippetFolder snippetFolder() { + return snippetFolder; + } + /** * Whether the palette is a stack of blocks with a thickness, rather than single blocks on walls or slopes. */ @@ -93,6 +100,38 @@ record BiomePalette(String biomeKey, Kind kind, int index) { } } + /** + * The folders under snippet/ that palettes can come from, named after the Iris type they hold. + */ + enum SnippetFolder { + BLOCK_PALETTES("biome-palette", "Block palettes"), + DECORATORS("decorator", "Decorators"); + + private final String folder; + private final String label; + + SnippetFolder(String folder, String label) { + this.folder = folder; + this.label = label; + } + + String folder() { + return folder; + } + + String label() { + return label; + } + + /** + * The snippet's path inside this folder. Packs may leave the folder out, the way the snippet loader allows. + */ + String pathOf(String reference) { + String path = reference.startsWith(SNIPPET_PREFIX) ? reference.substring(SNIPPET_PREFIX.length()) : reference; + return path.startsWith(folder + "/") ? path.substring(folder.length() + 1) : path; + } + } + static List allOf(String biomeKey, IrisBiome biome) { List palettes = new ArrayList<>(); for (Kind kind : Kind.values()) { diff --git a/core/src/main/java/com/volmit/iris/core/gui/BlockTextures.java b/core/src/main/java/com/volmit/iris/core/gui/BlockTextures.java index 538c87d0c..f6c6349f0 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/BlockTextures.java +++ b/core/src/main/java/com/volmit/iris/core/gui/BlockTextures.java @@ -43,7 +43,7 @@ final class BlockTextures { private final ZipFile clientJar; private final MinecraftBiomeColors biomeColors; - private final Map> topTextures = new ConcurrentHashMap<>(); + private final Map> resolvedTextures = new ConcurrentHashMap<>(); private BlockTextures(ZipFile clientJar, MinecraftBiomeColors biomeColors) { this.clientJar = clientJar; @@ -71,12 +71,27 @@ final class BlockTextures { return biomeColors; } + /** + * What the block looks like from above. Plants and other side-on sprites become their average colour, because + * their sprite would read as stripes across a stack of ground blocks. + */ Optional topTexture(BlockData block) { - // Blockstate files match on every property, so defaults the pack left out have to be spelled out too. - return topTextures.computeIfAbsent(block.getAsString(false), this::resolveTopTexture); + return resolve(block).map(resolved -> resolved.isSideOnSprite() ? resolved.texture().flattened() : resolved.texture()); } - private Optional resolveTopTexture(String blockState) { + /** + * Like {@link #topTexture}, but plants keep their sprite, so single decorations on the ground stay recognisable. + */ + Optional iconTexture(BlockData block) { + return resolve(block).map(ResolvedTexture::texture); + } + + // Blockstate files match on every property, so defaults the pack left out have to be spelled out too. + private Optional resolve(BlockData block) { + return resolvedTextures.computeIfAbsent(block.getAsString(false), this::resolveTopTexture); + } + + private Optional resolveTopTexture(String blockState) { try { BlockStateId state = BlockStateId.parse(blockState); JsonObject definition = readJson(assetPath(state.namespace() + ":" + state.name(), "blockstates", ".json")); @@ -97,7 +112,7 @@ final class BlockTextures { MinecraftBlockTints.fixedTint(state.name(), state.properties(), face.tintIndex()), MinecraftBlockTints.biomeTint(state.name(), face.tintIndex()).orElse(null) ); - return Optional.of(face.isSideOnSprite() ? texture.flattened() : texture); + return Optional.of(new ResolvedTexture(texture, face.isSideOnSprite())); } catch (IOException | RuntimeException error) { return Optional.empty(); } @@ -284,6 +299,9 @@ final class BlockTextures { private record ModelFace(String texture, int tintIndex, boolean isSideOnSprite) { } + private record ResolvedTexture(BlockTexture texture, boolean isSideOnSprite) { + } + private record BlockStateId(String namespace, String name, Map properties) { private static BlockStateId parse(String blockState) { int propertiesStart = blockState.indexOf('['); diff --git a/core/src/main/java/com/volmit/iris/core/gui/PaletteCatalog.java b/core/src/main/java/com/volmit/iris/core/gui/PaletteCatalog.java index 62ded2dbe..0a8d40c48 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/PaletteCatalog.java +++ b/core/src/main/java/com/volmit/iris/core/gui/PaletteCatalog.java @@ -35,6 +35,7 @@ import java.io.FileReader; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; +import java.util.Comparator; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -43,7 +44,8 @@ import java.util.Locale; import java.util.Map; import java.util.Set; -record PaletteCatalog(List regions, List biomesOutsideRegions, Map biomes) { +record PaletteCatalog(List regions, List biomesOutsideRegions, Map biomes, + List snippets) { private static final int SUMMARIZED_BLOCK_COUNT = 3; static PaletteCatalog load(Engine engine) { @@ -61,7 +63,33 @@ record PaletteCatalog(List regions, List biomesOutsideRegio entry.groups().forEach(group -> group.biomeKeys().forEach(key -> collectWithChildren(key, biomes, biomesInRegions))); } List outside = biomes.keySet().stream().filter(key -> !biomesInRegions.contains(key)).toList(); - return new PaletteCatalog(List.copyOf(regions), outside, Map.copyOf(biomes)); + return new PaletteCatalog(List.copyOf(regions), outside, Map.copyOf(biomes), loadSnippets(data, biomes)); + } + + // Snippet files no biome refers to are listed too, so a new snippet shows up before it is put to use. + private static List loadSnippets(IrisData data, Map biomes) { + Map snippets = new LinkedHashMap<>(); + for (BiomeEntry biome : biomes.values()) { + for (PaletteSummary summary : biome.palettes()) { + if (summary.snippet() == null) { + continue; + } + BiomePalette.SnippetFolder folder = summary.palette().kind().snippetFolder(); + snippets.computeIfAbsent(folder.folder() + "/" + summary.snippet(), + key -> new SnippetEntry(folder, summary.snippet(), summary.blocks(), new ArrayList<>())) + .uses().add(summary.palette()); + } + } + for (BiomePalette.SnippetFolder folder : BiomePalette.SnippetFolder.values()) { + for (String reference : data.getPossibleSnippets(folder.folder())) { + String path = folder.pathOf(reference); + snippets.putIfAbsent(folder.folder() + "/" + path, new SnippetEntry(folder, path, null, List.of())); + } + } + return snippets.values().stream() + .map(snippet -> new SnippetEntry(snippet.folder(), snippet.path(), snippet.blocks(), List.copyOf(snippet.uses()))) + .sorted(Comparator.comparing(SnippetEntry::folder).thenComparing(SnippetEntry::path)) + .toList(); } // A studio focus region is generated even when the dimension's region list doesn't include it. @@ -143,8 +171,7 @@ record PaletteCatalog(List regions, List biomesOutsideRegio if (element == null || !element.isJsonPrimitive()) { return null; } - String reference = element.getAsString(); - return reference.substring(reference.lastIndexOf('/') + 1); + return palette.kind().snippetFolder().pathOf(element.getAsString()); } private static JsonObject readSource(File file) { @@ -211,7 +238,20 @@ record PaletteCatalog(List regions, List biomesOutsideRegio } /** - * @param snippet the name of the snippet file the palette comes from, or null when the biome defines it itself + * A snippet file palettes can come from. + * + * @param blocks a summary of its blocks, or null when no biome uses it + * @param uses the palettes made from it, in biome order + */ + record SnippetEntry(BiomePalette.SnippetFolder folder, String path, String blocks, List uses) { + boolean matches(String filter) { + return path.toLowerCase(Locale.ROOT).contains(filter) + || (blocks != null && blocks.toLowerCase(Locale.ROOT).contains(filter)); + } + } + + /** + * @param snippet the path of the snippet file the palette comes from, or null when the biome defines it itself */ record PaletteSummary(BiomePalette palette, String snippet, String blocks) { String text() { 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 index ad206e41f..0cbd7d2c6 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerPreview.java +++ b/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerPreview.java @@ -31,6 +31,7 @@ import org.bukkit.block.data.BlockData; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Optional; final class PaletteLayerPreview { static final int HISTOGRAM_BINS = 50; @@ -206,11 +207,15 @@ final class PaletteLayerPreview { } for (int entry = 0; entry < blocks.size(); entry++) { BlockData block = blocks.get(entry).getBlockData(data); - resolved[entry] = block == null ? null : textures.topTexture(block).orElse(null); + resolved[entry] = block == null ? null : textureOf(block, textures).orElse(null); } return resolved; } + private Optional textureOf(BlockData block, BlockTextures textures) { + return palette.kind() == BiomePalette.Kind.DECORATOR ? textures.iconTexture(block) : textures.topTexture(block); + } + // The legend shows tinted blocks as they look at the world origin, where the statistics are sampled too. private BlockTexture[] tintForLegend(BlockTexture[] textures) { BlockTexture[] tinted = new BlockTexture[textures.length]; diff --git a/core/src/main/java/com/volmit/iris/core/gui/PaletteNavigator.java b/core/src/main/java/com/volmit/iris/core/gui/PaletteNavigator.java index b717bf278..546c24abc 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/PaletteNavigator.java +++ b/core/src/main/java/com/volmit/iris/core/gui/PaletteNavigator.java @@ -154,7 +154,7 @@ final class PaletteNavigator extends JPanel { private JPanel buildOverviewCard() { JPanel card = new JPanel(new BorderLayout()); - StudioToolbar.makeSearchField(searchField, "Search biomes or blocks..."); + StudioToolbar.makeSearchField(searchField, "Search biomes, snippets or blocks..."); searchField.getDocument().addDocumentListener(new DocumentListener() { @Override public void insertUpdate(DocumentEvent event) { @@ -310,6 +310,7 @@ final class PaletteNavigator extends JPanel { outside.setUserObject(new OverviewNode("Not in any region", outside.getChildCount() + " biomes", null, null)); root.add(outside); } + addSnippetNodes(root, filter); updatingSelection = true; try { treeModel.setRoot(root); @@ -340,6 +341,38 @@ final class PaletteNavigator extends JPanel { } } + // Snippets sit below the regions, so a palette picked elsewhere is still found in its biome first. + private void addSnippetNodes(DefaultMutableTreeNode root, String filter) { + for (BiomePalette.SnippetFolder folder : BiomePalette.SnippetFolder.values()) { + DefaultMutableTreeNode folderNode = new DefaultMutableTreeNode(); + catalog.snippets().stream() + .filter(snippet -> snippet.folder() == folder && (filter.isEmpty() || snippet.matches(filter))) + .forEach(snippet -> folderNode.add(snippetNode(snippet))); + if (folderNode.getChildCount() > 0) { + folderNode.setUserObject(new OverviewNode(folder.label() + " snippets", folderNode.getChildCount() + " files", null, null)); + root.add(folderNode); + } + } + } + + // Picking a snippet opens it where the first biome using it places it; its children open the other uses. + private DefaultMutableTreeNode snippetNode(PaletteCatalog.SnippetEntry snippet) { + if (snippet.uses().isEmpty()) { + return new DefaultMutableTreeNode(new OverviewNode(snippet.path(), "not used by any biome", null, null)); + } + BiomePalette firstUse = snippet.uses().get(0); + String usedBy = snippet.uses().size() == 1 ? "used once" : "used " + snippet.uses().size() + " times"; + DefaultMutableTreeNode node = new DefaultMutableTreeNode(new OverviewNode(snippet.path() + " · " + snippet.blocks(), + usedBy, firstUse.biomeKey(), firstUse)); + for (BiomePalette use : snippet.uses()) { + BiomeEntry biome = catalog.biome(use.biomeKey()); + String biomeName = biome == null ? use.biomeKey() : biome.name(); + node.add(new DefaultMutableTreeNode(new OverviewNode(biomeName + " · " + use.lowercaseLabel(), use.biomeKey(), + use.biomeKey(), use))); + } + return node; + } + private void expandDownToBiomes(DefaultMutableTreeNode root) { Enumeration nodes = root.breadthFirstEnumeration(); while (nodes.hasMoreElements()) { @@ -351,6 +384,9 @@ final class PaletteNavigator extends JPanel { } private void selectInTree() { + if (selectedNodeShowsSelectedPalette()) { + return; + } DefaultMutableTreeNode match = findLayerNode((DefaultMutableTreeNode) treeModel.getRoot()); if (match == null) { overviewTree.clearSelection(); @@ -365,6 +401,13 @@ final class PaletteNavigator extends JPanel { } } + // The same palette can be listed under its biome and under its snippet; the row the user picked stays picked. + private boolean selectedNodeShowsSelectedPalette() { + return nodeAt(overviewTree.getSelectionPath()) instanceof OverviewNode node + && node.palette() != null + && node.palette().equals(selectedPalette); + } + private DefaultMutableTreeNode findLayerNode(DefaultMutableTreeNode root) { Enumeration nodes = root.depthFirstEnumeration(); while (nodes.hasMoreElements()) { @@ -477,7 +520,8 @@ final class PaletteNavigator extends JPanel { Object userObject = ((DefaultMutableTreeNode) value).getUserObject(); String text = userObject instanceof OverviewNode node ? node.html() : ""; super.getTreeCellRendererComponent(tree, text, selected, expanded, leaf, row, false); - setFont(userObject instanceof OverviewNode node && node.biomeKey() == null ? StudioTheme.boldFont() : StudioTheme.font()); + boolean groupsOthers = userObject instanceof OverviewNode node && node.biomeKey() == null && !leaf; + setFont(groupsOthers ? StudioTheme.boldFont() : StudioTheme.font()); return this; } }