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
This commit is contained in:
piexel committed 2026-10-19 17:21:01 +02:00
1 parent e4675ecab8
commit 84ffce263a
5 files changed
+166 -20

No files matched your search

@@ -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<BiomePalette> allOf(String biomeKey, IrisBiome biome) {
List<BiomePalette> palettes = new ArrayList<>();
for (Kind kind : Kind.values()) {
@@ -43,7 +43,7 @@ final class BlockTextures {
private final ZipFile clientJar;
private final MinecraftBiomeColors biomeColors;
private final Map<String, Optional<BlockTexture>> topTextures = new ConcurrentHashMap<>();
private final Map<String, Optional<ResolvedTexture>> 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<BlockTexture> 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<BlockTexture> resolveTopTexture(String blockState) {
/**
* Like {@link #topTexture}, but plants keep their sprite, so single decorations on the ground stay recognisable.
*/
Optional<BlockTexture> 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<ResolvedTexture> resolve(BlockData block) {
return resolvedTextures.computeIfAbsent(block.getAsString(false), this::resolveTopTexture);
}
private Optional<ResolvedTexture> 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<String, String> properties) {
private static BlockStateId parse(String blockState) {
int propertiesStart = blockState.indexOf('[');
@@ -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<RegionEntry> regions, List<String> biomesOutsideRegions, Map<String, BiomeEntry> biomes) {
record PaletteCatalog(List<RegionEntry> regions, List<String> biomesOutsideRegions, Map<String, BiomeEntry> biomes,
List<SnippetEntry> snippets) {
private static final int SUMMARIZED_BLOCK_COUNT = 3;
static PaletteCatalog load(Engine engine) {
@@ -61,7 +63,33 @@ record PaletteCatalog(List<RegionEntry> regions, List<String> biomesOutsideRegio
entry.groups().forEach(group -> group.biomeKeys().forEach(key -> collectWithChildren(key, biomes, biomesInRegions)));
}
List<String> 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<SnippetEntry> loadSnippets(IrisData data, Map<String, BiomeEntry> biomes) {
Map<String, SnippetEntry> 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<RegionEntry> regions, List<String> 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<RegionEntry> regions, List<String> 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<BiomePalette> 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() {
@@ -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<BlockTexture> 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];
@@ -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;
}
}