Add an Objects view to the studio map

Inspired by the Procedural Objects workbench, the map window gets an
Objects view (key O) to browse every object in the pack:

- The sidebar lists the objects as a folder tree, with a search that
  matches every typed word anywhere in an object's path.
- The object is drawn in 3D with Minecraft's textures by a small
  software renderer: an orthographic camera, a depth buffer and only
  the block sides that can be seen. Drag orbits, shift-drag or the
  middle button pans, scrolling zooms and R resets. Plants are drawn
  as crossed sprites, single box models like slabs and carpets keep
  their shape, grass and leaves get plains' colours, and the object
  is centred beside the floating sidebar. A 150,000 block tree draws
  in about 55 ms.
- The right panel shows the object's size, block count, block types
  and how much of its box it fills, with a filterable list of its
  blocks by share.

BlockTextures can now resolve a block's texture on every side through
its blockstate rotation, sharing the model reading with the top
texture lookup.

Model used: Claude Opus 5.5
This commit is contained in:
piexel committed 2026-10-19 20:29:53 +02:00
1 parent 5a99091193
commit a02b4386a4
13 files changed
+2000 -28

No files matched your search

@@ -0,0 +1,127 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.volmit.iris.core.gui;
import com.volmit.iris.core.gui.BlockTextures.BlockTexture;
import java.util.EnumMap;
import java.util.Map;
/**
* A block as the object viewer draws it: a box with a texture on each side, or two crossed sprites for plants.
* Models made of several boxes, like stairs, are drawn as a full block.
*
* @param box the box in block units, from 0 to 1 on every axis for a full block
* @param faces the texture on each side of the box; a side the model leaves open is missing
* @param crossSprite the sprite drawn on two crossed planes, or null for a box
*/
record BlockModel(Box box, Map<CubeFace, BlockTexture> faces, BlockTexture crossSprite) {
static BlockModel box(Box box, Map<CubeFace, BlockTexture> faces) {
return new BlockModel(box, Map.copyOf(faces), null);
}
static BlockModel cross(BlockTexture sprite) {
return new BlockModel(Box.FULL, Map.of(), sprite);
}
static BlockModel solidColor(int color) {
BlockTexture texture = new BlockTexture(1, new int[]{color}, new boolean[]{true}, color, null);
Map<CubeFace, BlockTexture> faces = new EnumMap<>(CubeFace.class);
for (CubeFace face : CubeFace.values()) {
faces.put(face, texture);
}
return box(Box.FULL, faces);
}
boolean isCross() {
return crossSprite != null;
}
/**
* Whether a neighbour's side touching this block can never be seen.
*/
boolean hidesTouchingSides() {
if (isCross() || !box.isFull() || faces.size() < CubeFace.values().length) {
return false;
}
for (BlockTexture texture : faces.values()) {
for (boolean opaque : texture.opaque()) {
if (!opaque) {
return false;
}
}
}
return true;
}
record Box(double minX, double minY, double minZ, double maxX, double maxY, double maxZ) {
static final Box FULL = new Box(0D, 0D, 0D, 1D, 1D, 1D);
private static final double BLOCK_CENTER = 0.5D;
static Box ofModelElement(double[] from, double[] to) {
double pixels = 16D;
return new Box(from[0] / pixels, from[1] / pixels, from[2] / pixels, to[0] / pixels, to[1] / pixels, to[2] / pixels);
}
boolean isFull() {
return equals(FULL);
}
double min(int axis) {
return switch (axis) {
case 0 -> minX;
case 1 -> minY;
default -> minZ;
};
}
double max(int axis) {
return switch (axis) {
case 0 -> maxX;
case 1 -> maxY;
default -> maxZ;
};
}
// Blockstates turn a model around the block's centre, the x axis first.
Box rotated(int rotationX, int rotationY) {
if (rotationX % 360 == 0 && rotationY % 360 == 0) {
return this;
}
double[] first = turned(minX, minY, minZ, rotationX, rotationY);
double[] second = turned(maxX, maxY, maxZ, rotationX, rotationY);
return new Box(Math.min(first[0], second[0]), Math.min(first[1], second[1]), Math.min(first[2], second[2]),
Math.max(first[0], second[0]), Math.max(first[1], second[1]), Math.max(first[2], second[2]));
}
private static double[] turned(double x, double y, double z, int rotationX, int rotationY) {
int[] axisX = CubeFace.turnAroundY(CubeFace.turnAroundX(new int[]{1, 0, 0}, rotationX), rotationY);
int[] axisY = CubeFace.turnAroundY(CubeFace.turnAroundX(new int[]{0, 1, 0}, rotationX), rotationY);
int[] axisZ = CubeFace.turnAroundY(CubeFace.turnAroundX(new int[]{0, 0, 1}, rotationX), rotationY);
double offsetX = x - BLOCK_CENTER;
double offsetY = y - BLOCK_CENTER;
double offsetZ = z - BLOCK_CENTER;
return new double[]{
BLOCK_CENTER + axisX[0] * offsetX + axisY[0] * offsetY + axisZ[0] * offsetZ,
BLOCK_CENTER + axisX[1] * offsetX + axisY[1] * offsetY + axisZ[1] * offsetZ,
BLOCK_CENTER + axisX[2] * offsetX + axisY[2] * offsetY + axisZ[2] * offsetZ
};
}
}
}
@@ -28,6 +28,7 @@ import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.Arrays;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
@@ -49,6 +50,7 @@ final class BlockTextures {
private final ZipFile clientJar;
private final MinecraftBiomeColors biomeColors;
private final Map<String, Optional<ResolvedTexture>> resolvedTextures = new ConcurrentHashMap<>();
private final Map<String, Optional<BlockModel>> resolvedModels = new ConcurrentHashMap<>();
private BlockTextures(ZipFile clientJar, MinecraftBiomeColors biomeColors) {
this.clientJar = clientJar;
@@ -103,6 +105,13 @@ final class BlockTextures {
return resolve(withHalf(block, Bisected.Half.TOP)).filter(ResolvedTexture::isSideOnSprite).map(ResolvedTexture::texture);
}
/**
* What the block looks like from every side, for drawing it in 3D.
*/
Optional<BlockModel> model(BlockData block) {
return resolvedModels.computeIfAbsent(block.getAsString(false), this::resolveModel);
}
private static BlockData withHalf(BlockData block, Bisected.Half half) {
BlockData copy = block.clone();
((Bisected) copy).setHalf(half);
@@ -117,41 +126,81 @@ final class BlockTextures {
private Optional<ResolvedTexture> resolveTopTexture(String blockState) {
try {
BlockStateId state = BlockStateId.parse(blockState);
JsonObject definition = readJson(assetPath(state.namespace() + ":" + state.name(), "blockstates", ".json"));
JsonObject variant = definition == null ? null : pickVariant(definition, state.properties());
JsonObject variant = variantOf(state);
if (variant == null || !variant.has("model")) {
return Optional.empty();
}
ModelFace face = findVisibleFace(variant.get("model").getAsString(), faceSeenFromAbove(variant));
ModelFace face = readModel(variant.get("model").getAsString()).visibleFace(faceSeenFromAbove(variant));
if (face == null) {
return Optional.empty();
}
BufferedImage image = readLayeredImage(face.textures());
if (image == null) {
return readTexture(state, face).map(texture -> new ResolvedTexture(texture, face.isSideOnSprite()));
} catch (IOException | RuntimeException error) {
return Optional.empty();
}
}
private Optional<BlockModel> resolveModel(String blockState) {
try {
BlockStateId state = BlockStateId.parse(blockState);
JsonObject variant = variantOf(state);
if (variant == null || !variant.has("model")) {
return Optional.empty();
}
BlockTexture texture = BlockTexture.fromImage(
ModelDefinition model = readModel(variant.get("model").getAsString());
if (model.isSprite()) {
return readTexture(state, model.spriteFace()).map(BlockModel::cross);
}
int rotationX = rotationOf(variant, "x");
int rotationY = rotationOf(variant, "y");
Map<CubeFace, BlockTexture> faces = new EnumMap<>(CubeFace.class);
for (CubeFace face : CubeFace.values()) {
ModelFace modelFace = model.sideFace(face.beforeRotation(rotationX, rotationY).modelName());
if (modelFace != null) {
readTexture(state, modelFace).ifPresent(texture -> faces.put(face, texture));
}
}
return faces.isEmpty() ? Optional.empty() : Optional.of(BlockModel.box(model.box().rotated(rotationX, rotationY), faces));
} catch (IOException | RuntimeException error) {
return Optional.empty();
}
}
private JsonObject variantOf(BlockStateId state) throws IOException {
JsonObject definition = readJson(assetPath(state.namespace() + ":" + state.name(), "blockstates", ".json"));
return definition == null ? null : pickVariant(definition, state.properties());
}
private static int rotationOf(JsonObject variant, String axis) {
return variant.has(axis) ? Math.floorMod(variant.get(axis).getAsInt(), 360) : 0;
}
private Optional<BlockTexture> readTexture(BlockStateId state, ModelFace face) throws IOException {
BufferedImage image = readLayeredImage(face.textures());
if (image == null) {
return Optional.empty();
}
try {
return Optional.of(BlockTexture.fromImage(
image,
MinecraftBlockTints.fixedTint(state.name(), state.properties(), face.tintIndex()),
MinecraftBlockTints.biomeTint(state.name(), face.tintIndex()).orElse(null)
);
return Optional.of(new ResolvedTexture(texture, face.isSideOnSprite()));
} catch (IOException | RuntimeException error) {
));
} catch (IllegalArgumentException invisibleTexture) {
return Optional.empty();
}
}
// A block rotated on its x axis, like a sideways log, shows a side face on top.
private static String faceSeenFromAbove(JsonObject variant) {
int rotationX = variant.has("x") ? Math.floorMod(variant.get("x").getAsInt(), 360) : 0;
return switch (rotationX) {
return switch (rotationOf(variant, "x")) {
case 90, 270 -> "north";
case 180 -> "down";
default -> "up";
};
}
private ModelFace findVisibleFace(String modelReference, String faceName) throws IOException {
private ModelDefinition readModel(String modelReference) throws IOException {
Map<String, String> textures = new HashMap<>();
JsonArray elements = null;
String current = modelReference;
@@ -170,18 +219,7 @@ final class BlockTextures {
}
current = model.has("parent") ? model.get("parent").getAsString() : null;
}
JsonObject face = elements == null ? null : topmostFace(elements, faceName);
if (face != null) {
String texture = dereference(face.get("texture").getAsString(), textures);
return texture == null ? null : new ModelFace(List.of(texture), tintIndexOf(face), false);
}
List<String> spriteLayers = elements == null ? List.of() : spriteLayers(elements, textures);
if (!spriteLayers.isEmpty()) {
return new ModelFace(spriteLayers, firstTintIndex(elements), true);
}
String particle = dereference("#particle", textures);
return particle == null ? null : new ModelFace(List.of(particle), NO_TINT_INDEX, false);
return new ModelDefinition(textures, elements);
}
// Some plants draw a second sprite over the first, like the glowing fireflies on a firefly bush.
@@ -366,6 +404,63 @@ final class BlockTextures {
private record ModelFace(List<String> textures, int tintIndex, boolean isSideOnSprite) {
}
/**
* A block model with its parents' textures merged in. Models drawn in code, like chests, have no elements.
*/
private record ModelDefinition(Map<String, String> textures, JsonArray elements) {
ModelFace visibleFace(String faceName) {
ModelFace face = sideFace(faceName);
if (face != null) {
return face;
}
return elements != null && !spriteLayers(elements, textures).isEmpty() ? spriteFace() : particleFace();
}
// The face's texture on the model's topmost box, the particle texture for models without boxes, or null when
// the model leaves that side open.
ModelFace sideFace(String faceName) {
if (elements == null) {
return particleFace();
}
JsonObject face = topmostFace(elements, faceName);
if (face == null) {
return null;
}
String texture = dereference(face.get("texture").getAsString(), textures);
return texture == null ? null : new ModelFace(List.of(texture), tintIndexOf(face), false);
}
// Plants have only upright planes, so nothing of them faces up or down.
boolean isSprite() {
return elements != null
&& topmostFace(elements, CubeFace.UP.modelName()) == null
&& topmostFace(elements, CubeFace.DOWN.modelName()) == null
&& !spriteLayers(elements, textures).isEmpty();
}
ModelFace spriteFace() {
return new ModelFace(spriteLayers(elements, textures), firstTintIndex(elements), true);
}
private ModelFace particleFace() {
String particle = dereference("#particle", textures);
return particle == null ? null : new ModelFace(List.of(particle), NO_TINT_INDEX, false);
}
// One box, like a slab or a carpet, keeps its shape; several, like stairs, become a full block.
BlockModel.Box box() {
if (elements == null || elements.size() != 1) {
return BlockModel.Box.FULL;
}
JsonObject element = elements.get(0).getAsJsonObject();
return BlockModel.Box.ofModelElement(coordinates(element.getAsJsonArray("from")), coordinates(element.getAsJsonArray("to")));
}
private static double[] coordinates(JsonArray values) {
return new double[]{values.get(0).getAsDouble(), values.get(1).getAsDouble(), values.get(2).getAsDouble()};
}
}
private record ResolvedTexture(BlockTexture texture, boolean isSideOnSprite) {
}
@@ -0,0 +1,113 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.volmit.iris.core.gui;
/**
* The six sides of a block, named as in Minecraft's model files, with the fixed light each side gets in the object viewer.
*/
enum CubeFace {
UP("up", 0, 1, 0, 1.00D),
DOWN("down", 0, -1, 0, 0.48D),
NORTH("north", 0, 0, -1, 0.62D),
SOUTH("south", 0, 0, 1, 0.88D),
EAST("east", 1, 0, 0, 0.80D),
WEST("west", -1, 0, 0, 0.69D);
private final String modelName;
private final int stepX;
private final int stepY;
private final int stepZ;
private final double shade;
CubeFace(String modelName, int stepX, int stepY, int stepZ, double shade) {
this.modelName = modelName;
this.stepX = stepX;
this.stepY = stepY;
this.stepZ = stepZ;
this.shade = shade;
}
String modelName() {
return modelName;
}
int stepX() {
return stepX;
}
int stepY() {
return stepY;
}
int stepZ() {
return stepZ;
}
double shade() {
return shade;
}
/**
* The side of the model that ends up facing this way once a blockstate turns the model, which Minecraft does around
* the x axis first and the y axis second, each in steps of 90 degrees.
*/
CubeFace beforeRotation(int rotationX, int rotationY) {
int[] direction = {stepX, stepY, stepZ};
direction = turnAroundY(direction, -rotationY);
direction = turnAroundX(direction, -rotationX);
return pointing(direction[0], direction[1], direction[2]);
}
static int[] turnAroundX(int[] vector, int degrees) {
int cos = cosine(degrees);
int sin = sine(degrees);
return new int[]{vector[0], vector[1] * cos - vector[2] * sin, vector[1] * sin + vector[2] * cos};
}
static int[] turnAroundY(int[] vector, int degrees) {
int cos = cosine(degrees);
int sin = sine(degrees);
return new int[]{vector[0] * cos - vector[2] * sin, vector[1], vector[0] * sin + vector[2] * cos};
}
private static int cosine(int degrees) {
return switch (Math.floorMod(degrees, 360)) {
case 90, 270 -> 0;
case 180 -> -1;
default -> 1;
};
}
private static int sine(int degrees) {
return switch (Math.floorMod(degrees, 360)) {
case 90 -> 1;
case 270 -> -1;
default -> 0;
};
}
private static CubeFace pointing(int x, int y, int z) {
for (CubeFace face : values()) {
if (face.stepX == x && face.stepY == y && face.stepZ == z) {
return face;
}
}
throw new IllegalArgumentException("Not a block side: " + x + ", " + y + ", " + z);
}
}
@@ -38,6 +38,8 @@ enum MapView {
CAVE_BIOMES("Cave biomes", "The cave biome under each spot", Group.BIOME_STREAMS, RenderType.CAVE_LAND, KeyEvent.VK_7),
PALETTES("Palettes", "The blocks each biome's palette layers place, with their textures",
Group.BLOCKS, null, KeyEvent.VK_P),
OBJECTS("Objects", "Every object in the pack in 3D, with the blocks it is made of",
Group.BLOCKS, null, KeyEvent.VK_O),
NOISE("Noise", "The pack's noise generators and Iris' built-in noise styles",
Group.GENERATORS, null, KeyEvent.VK_N);
@@ -68,7 +70,7 @@ enum MapView {
}
/**
* The tile renderer's mode for this view, or null for the palette and noise views, which draw themselves.
* The tile renderer's mode for this view, or null for the palette, object and noise views, which draw themselves.
*/
RenderType renderType() {
return renderType;
@@ -40,6 +40,8 @@ import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.KeyboardFocusManager;
import java.awt.event.ActionEvent;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.KeyEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
@@ -47,12 +49,13 @@ import java.util.ArrayList;
import java.util.List;
/**
* The studio map: one window showing the world as tiles in several views, with the palette and noise explorers as its
* Palettes and Noise views.
* The studio map: one window showing the world as tiles in several views, with the palette, object and noise explorers
* as its Palettes, Objects and Noise views.
*/
public final class MapWindow implements MapCanvas.Listener {
private static final String MAP_CARD = "map";
private static final String PALETTES_CARD = "palettes";
private static final String OBJECTS_CARD = "objects";
private static final String NOISE_CARD = "noise";
private static final int SIDEBAR_WIDTH = 270;
private static final int SIDEBAR_MINIMUM_WIDTH = 200;
@@ -69,6 +72,7 @@ public final class MapWindow implements MapCanvas.Listener {
private final JPanel content = new JPanel(new CardLayout());
private final JPanel statusBars = new JPanel(new CardLayout());
private final JPanel sidebar = new JPanel(new BorderLayout());
private final IslandPanel sidebarIsland = new IslandPanel(sidebar);
private final JPanel inspectorSlot = new JPanel(new BorderLayout());
private final JComponent viewListScroll;
private final JSplitPane explorerSplit = new JSplitPane(JSplitPane.VERTICAL_SPLIT);
@@ -86,6 +90,7 @@ public final class MapWindow implements MapCanvas.Listener {
private final MapLegendPanel legendPanel;
private NoiseExplorerGUI paletteExplorer;
private ObjectExplorer objectExplorer;
private NoiseExplorerGUI noiseExplorer;
private JComponent noiseSidebar;
private MapView currentView;
@@ -131,7 +136,7 @@ public final class MapWindow implements MapCanvas.Listener {
frame.setLayout(new BorderLayout());
frame.add(buildToolbar(), BorderLayout.NORTH);
frame.add(new IslandOverlay(new IslandPanel(sidebar), content, StudioTheme.scale(SIDEBAR_WIDTH),
frame.add(new IslandOverlay(sidebarIsland, content, StudioTheme.scale(SIDEBAR_WIDTH),
StudioTheme.scale(SIDEBAR_MINIMUM_WIDTH), StudioTheme.scale(CONTENT_MINIMUM_WIDTH)), BorderLayout.CENTER);
frame.add(inspectorSlot, BorderLayout.EAST);
frame.add(statusBars, BorderLayout.SOUTH);
@@ -196,6 +201,8 @@ public final class MapWindow implements MapCanvas.Listener {
showMap(previousView, view);
} else if (view == MapView.PALETTES) {
showPalettes(previousView);
} else if (view == MapView.OBJECTS) {
showObjects();
} else {
showNoise();
}
@@ -236,6 +243,37 @@ public final class MapWindow implements MapCanvas.Listener {
}
}
private void showObjects() {
boolean firstOpen = objectExplorer == null;
if (firstOpen) {
createObjectExplorer();
}
canvas.setActive(false);
showControls(explorerControls);
useSidebar(viewListAbove(objectExplorer.browser()));
inspectorSlot.removeAll();
inspectorSlot.add(objectExplorer.overview(), BorderLayout.CENTER);
showCard(OBJECTS_CARD);
EventQueue.invokeLater(() -> viewList.select(currentView));
if (firstOpen) {
objectExplorer.open();
}
}
private void createObjectExplorer() {
objectExplorer = new ObjectExplorer(start.engine(), start.worldId());
ObjectViewport viewport = objectExplorer.viewport();
viewport.setCoveredLeftWidth(sidebarIsland.getWidth());
sidebarIsland.addComponentListener(new ComponentAdapter() {
@Override
public void componentResized(ComponentEvent event) {
viewport.setCoveredLeftWidth(sidebarIsland.getWidth());
}
});
content.add(viewport, OBJECTS_CARD);
statusBars.add(objectExplorer.statusBar(), OBJECTS_CARD);
}
private void showNoise() {
boolean firstOpen = noiseExplorer == null;
if (firstOpen) {
@@ -349,6 +387,10 @@ public final class MapWindow implements MapCanvas.Listener {
}
private void resetExplorerView() {
if (currentView == MapView.OBJECTS) {
objectExplorer.resetCamera();
return;
}
NoiseExplorerGUI explorer = currentView == MapView.NOISE ? noiseExplorer : paletteExplorer;
if (explorer != null) {
explorer.resetViewport();
@@ -441,6 +483,9 @@ public final class MapWindow implements MapCanvas.Listener {
if (paletteExplorer != null) {
paletteExplorer.close();
}
if (objectExplorer != null) {
objectExplorer.close();
}
if (noiseExplorer != null) {
noiseExplorer.close();
}
@@ -0,0 +1,270 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.volmit.iris.core.gui;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JTextField;
import javax.swing.JTree;
import javax.swing.ToolTipManager;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.tree.DefaultMutableTreeNode;
import javax.swing.tree.DefaultTreeCellRenderer;
import javax.swing.tree.DefaultTreeModel;
import javax.swing.tree.TreePath;
import javax.swing.tree.TreeSelectionModel;
import java.awt.BorderLayout;
import java.awt.Component;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Consumer;
/**
* Every object in the pack as a folder tree, with a search that matches every word typed anywhere in an object's path.
*/
final class ObjectBrowser extends JPanel {
private static final long serialVersionUID = 6795434561028735214L;
private static final int ROW_HEIGHT = 24;
private final Consumer<String> onObjectChosen;
private final JTextField searchField = new JTextField();
private final DefaultTreeModel treeModel = new DefaultTreeModel(new DefaultMutableTreeNode());
private final JTree tree = new JTree(treeModel);
private final JLabel countLabel = new JLabel("Loading objects...");
private List<String> objectKeys = List.of();
private String chosenKey;
private boolean updatingSelection;
ObjectBrowser(Consumer<String> onObjectChosen) {
super(new BorderLayout());
this.onObjectChosen = onObjectChosen;
StudioToolbar.makeSearchField(searchField, "Search objects...");
searchField.getDocument().addDocumentListener(new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent event) {
rebuildTree();
}
@Override
public void removeUpdate(DocumentEvent event) {
rebuildTree();
}
@Override
public void changedUpdate(DocumentEvent event) {
rebuildTree();
}
});
tree.setRootVisible(false);
tree.setShowsRootHandles(true);
tree.setRowHeight(StudioTheme.scale(ROW_HEIGHT));
tree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION);
tree.setCellRenderer(new EntryRenderer());
ToolTipManager.sharedInstance().registerComponent(tree);
tree.addTreeSelectionListener(event -> {
if (!updatingSelection && nodeAt(event.getPath()) instanceof Entry entry && entry.isObject()) {
chosenKey = entry.key();
onObjectChosen.accept(entry.key());
}
});
countLabel.setFont(StudioTheme.smallFont());
countLabel.setForeground(StudioTheme.SECONDARY_TEXT);
add(StudioToolbar.padded(searchField, 8, 8, 8, 8), BorderLayout.NORTH);
add(StudioToolbar.scroll(tree), BorderLayout.CENTER);
add(StudioToolbar.padded(countLabel, 6, 12, 8, 12), BorderLayout.SOUTH);
}
void showObjects(List<String> keys) {
objectKeys = keys.stream().sorted(Comparator.comparing(key -> key.toLowerCase(Locale.ROOT))).toList();
countLabel.setText(objectKeys.size() == 1 ? "1 object" : objectKeys.size() + " objects");
rebuildTree();
}
void showError(String text) {
countLabel.setText(text);
}
String firstObject() {
return objectKeys.isEmpty() ? null : objectKeys.get(0);
}
private void rebuildTree() {
String[] words = searchField.getText().trim().toLowerCase(Locale.ROOT).split("\\s+");
boolean searching = !searchField.getText().isBlank();
DefaultMutableTreeNode root = searching ? searchResults(words) : folderTree();
treeModel.setRoot(root);
if (searching) {
expandAll();
}
selectChosen();
}
private DefaultMutableTreeNode searchResults(String[] words) {
DefaultMutableTreeNode root = new DefaultMutableTreeNode();
for (String key : objectKeys) {
if (matchesEveryWord(key, words)) {
root.add(new DefaultMutableTreeNode(Entry.object(key, key)));
}
}
if (root.getChildCount() == 0) {
root.add(new DefaultMutableTreeNode(Entry.note("No matching objects")));
}
return root;
}
private static boolean matchesEveryWord(String key, String[] words) {
String lowerKey = key.toLowerCase(Locale.ROOT);
for (String word : words) {
if (!lowerKey.contains(word)) {
return false;
}
}
return true;
}
// Folders come before the objects beside them, like a file browser.
private DefaultMutableTreeNode folderTree() {
Folder top = new Folder();
for (String key : objectKeys) {
Folder folder = top;
String[] parts = key.split("/");
for (int index = 0; index < parts.length - 1; index++) {
folder = folder.subfolders.computeIfAbsent(parts[index], name -> new Folder());
}
folder.objects.add(key);
}
DefaultMutableTreeNode root = new DefaultMutableTreeNode();
addFolderContents(root, top, "");
return root;
}
private static void addFolderContents(DefaultMutableTreeNode node, Folder folder, String path) {
for (Map.Entry<String, Folder> subfolder : folder.subfolders.entrySet()) {
String subfolderPath = path + subfolder.getKey() + "/";
DefaultMutableTreeNode child = new DefaultMutableTreeNode(Entry.folder(subfolder.getKey(), subfolderPath));
addFolderContents(child, subfolder.getValue(), subfolderPath);
node.add(child);
}
for (String key : folder.objects) {
node.add(new DefaultMutableTreeNode(Entry.object(key.substring(key.lastIndexOf('/') + 1), key)));
}
}
void select(String key) {
chosenKey = key;
selectChosen();
}
private void selectChosen() {
DefaultMutableTreeNode node = chosenKey == null ? null : findObjectNode((DefaultMutableTreeNode) treeModel.getRoot());
updatingSelection = true;
try {
if (node == null) {
tree.clearSelection();
return;
}
TreePath path = new TreePath(node.getPath());
tree.setSelectionPath(path);
tree.scrollPathToVisible(path);
} finally {
updatingSelection = false;
}
}
private DefaultMutableTreeNode findObjectNode(DefaultMutableTreeNode node) {
if (node.getUserObject() instanceof Entry entry && entry.isObject() && entry.key().equals(chosenKey)) {
return node;
}
for (int index = 0; index < node.getChildCount(); index++) {
DefaultMutableTreeNode found = findObjectNode((DefaultMutableTreeNode) node.getChildAt(index));
if (found != null) {
return found;
}
}
return null;
}
private void expandAll() {
for (int row = 0; row < tree.getRowCount(); row++) {
tree.expandRow(row);
}
}
private static Object nodeAt(TreePath path) {
return path == null ? null : ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
}
private static final class Folder {
private final Map<String, Folder> subfolders = new LinkedHashMap<>();
private final List<String> objects = new ArrayList<>();
}
/**
* A row of the tree: an object with its key, a folder with its path, or a note without either.
*/
private record Entry(String label, String key, String folderPath) {
static Entry object(String label, String key) {
return new Entry(label, key, null);
}
static Entry folder(String label, String path) {
return new Entry(label, null, path);
}
static Entry note(String text) {
return new Entry(text, null, null);
}
boolean isObject() {
return key != null;
}
@Override
public String toString() {
return label;
}
}
private static final class EntryRenderer extends DefaultTreeCellRenderer {
private static final long serialVersionUID = -4316652201793418740L;
private EntryRenderer() {
setLeafIcon(null);
setOpenIcon(null);
setClosedIcon(null);
}
@Override
public Component getTreeCellRendererComponent(JTree tree, Object value, boolean selected, boolean expanded,
boolean leaf, int row, boolean focus) {
Object userObject = ((DefaultMutableTreeNode) value).getUserObject();
super.getTreeCellRendererComponent(tree, userObject == null ? "" : userObject.toString(), selected, expanded, leaf, row, false);
boolean isNote = userObject instanceof Entry entry && !entry.isObject() && entry.folderPath() == null;
setFont(StudioTheme.font());
setForeground(isNote ? StudioTheme.MUTED_TEXT : StudioTheme.TEXT);
setToolTipText(userObject instanceof Entry entry ? entry.isObject() ? entry.key() : entry.folderPath() : null);
return this;
}
}
}
@@ -0,0 +1,44 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.volmit.iris.core.gui;
/**
* Where the object viewer looks from: turned by yaw around the vertical axis, tilted down by pitch, both in radians.
* Zoom 1 fits the object in the view at the starting angle. Pan moves the picture in fractions of the view's size.
*/
record ObjectCamera(double yaw, double pitch, double zoom, double panX, double panY) {
static final ObjectCamera START = new ObjectCamera(0.76D, 0.38D, 1.72D, 0D, 0.025D);
private static final double RADIANS_PER_DRAGGED_PIXEL = 0.008D;
private static final double MAXIMUM_PITCH = 1.3D;
private static final double MINIMUM_ZOOM = 0.4D;
private static final double MAXIMUM_ZOOM = 12D;
ObjectCamera orbitedBy(double draggedX, double draggedY) {
double nextPitch = Math.max(-MAXIMUM_PITCH, Math.min(MAXIMUM_PITCH, pitch + draggedY * RADIANS_PER_DRAGGED_PIXEL));
return new ObjectCamera(yaw + draggedX * RADIANS_PER_DRAGGED_PIXEL, nextPitch, zoom, panX, panY);
}
ObjectCamera zoomedBy(double factor) {
return new ObjectCamera(yaw, pitch, Math.max(MINIMUM_ZOOM, Math.min(MAXIMUM_ZOOM, zoom * factor)), panX, panY);
}
ObjectCamera pannedBy(double fractionX, double fractionY) {
return new ObjectCamera(yaw, pitch, zoom, panX + fractionX, panY + fractionY);
}
}
@@ -0,0 +1,249 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.volmit.iris.core.gui;
import com.volmit.iris.core.gui.BlockTextures.BlockTexture;
import com.volmit.iris.engine.framework.Engine;
import com.volmit.iris.engine.object.IrisObject;
import org.bukkit.NamespacedKey;
import org.bukkit.block.data.BlockData;
import org.bukkit.util.BlockVector;
import javax.swing.JComponent;
import java.awt.EventQueue;
import java.awt.image.BufferedImage;
import java.util.Arrays;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicLong;
/**
* The map window's Objects view: every object in the pack in a folder tree, the chosen one in 3D, and what it is made
* of. Inspired by the Procedural Objects workbench.
*/
final class ObjectExplorer {
private static final NamespacedKey TINT_BIOME = NamespacedKey.minecraft("plains");
private static final int UNKNOWN_BLOCK_COLOR = 0x8A8A8A;
private static final String CAMERA_HINT = "Drag to orbit · Shift-drag to pan · Scroll to zoom · R resets";
private final Engine startEngine;
private final UUID worldId;
private final ObjectBrowser browser = new ObjectBrowser(this::showObject);
private final ObjectViewport viewport = new ObjectViewport();
private final ObjectOverviewPanel overview = new ObjectOverviewPanel();
private final StudioStatusBar statusBar = new StudioStatusBar();
private final ExecutorService loader = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "Iris Object Loader");
thread.setDaemon(true);
thread.setPriority(Thread.MIN_PRIORITY);
return thread;
});
private final AtomicLong objectRevision = new AtomicLong();
private final Map<String, BlockModel> models = new ConcurrentHashMap<>();
private final Runnable hotloadHook = this::reloadPack;
private volatile BlockTextures textures;
private volatile BiomeTintColors tintColors;
private String shownKey;
ObjectExplorer(Engine engine, UUID worldId) {
this.startEngine = engine;
this.worldId = worldId;
GuiHost.interruptOnPluginShutdown(loader);
GuiHost.registerHotloadHook(hotloadHook);
statusBar.showPosition(CAMERA_HINT);
}
JComponent browser() {
return browser;
}
ObjectViewport viewport() {
return viewport;
}
JComponent overview() {
return overview;
}
StudioStatusBar statusBar() {
return statusBar;
}
void resetCamera() {
viewport.resetCamera();
}
// Textures load first, since the first object would otherwise be drawn in flat colours.
void open() {
statusBar.showActivity("Loading Minecraft textures...");
loader.execute(() -> {
loadTextures();
List<String> keys = objectKeys();
EventQueue.invokeLater(() -> showCatalog(keys));
});
}
void close() {
GuiHost.unregisterHotloadHook(hotloadHook);
objectRevision.incrementAndGet();
loader.shutdownNow();
viewport.close();
}
private void loadTextures() {
try {
BlockTextures loaded = BlockTextures.shared().join();
tintColors = loaded.biomeColors().vanilla(TINT_BIOME);
textures = loaded;
} catch (RuntimeException unavailable) {
EventQueue.invokeLater(() -> statusBar.showError("Minecraft textures are unavailable, objects use plain colours"));
}
}
private List<String> objectKeys() {
return Arrays.asList(engine().getData().getObjectLoader().getPossibleKeys());
}
private void showCatalog(List<String> keys) {
statusBar.showActivity("");
browser.showObjects(keys);
String key = shownKey != null && keys.contains(shownKey) ? shownKey : browser.firstObject();
if (key == null) {
viewport.showMessage("This pack has no objects");
overview.clear();
return;
}
browser.select(key);
showObject(key);
}
private void showObject(String key) {
shownKey = key;
long revision = objectRevision.incrementAndGet();
viewport.showMessage("Loading " + key + "...");
statusBar.showActivity("Loading " + key + "...");
loader.execute(() -> {
if (revision != objectRevision.get()) {
return;
}
try {
LoadedObject loaded = load(key);
EventQueue.invokeLater(() -> showLoaded(revision, loaded));
} catch (RuntimeException error) {
EventQueue.invokeLater(() -> showFailure(revision, key, error));
}
});
}
private LoadedObject load(String key) {
IrisObject object = engine().getData().getObjectLoader().load(key);
if (object == null) {
throw new IllegalStateException("The object file could not be read");
}
Map<BlockVector, BlockData> blocks = new HashMap<>();
object.getBlocks().forEach((position, block) -> {
if (!block.getMaterial().isAir()) {
blocks.put(position, block);
}
});
ObjectMesh mesh = ObjectMesh.build(blocks, this::modelOf);
ObjectSummary summary = ObjectSummary.of(key, mesh.bounds(), blocks.values(), this::iconOf);
return new LoadedObject(mesh, summary);
}
private void showLoaded(long revision, LoadedObject loaded) {
if (revision != objectRevision.get()) {
return;
}
statusBar.showActivity("");
viewport.show(loaded.mesh());
overview.show(loaded.summary());
statusBar.showSummary(String.format(Locale.ROOT, "%s · %,d blocks · %,d faces drawn",
loaded.summary().key(), loaded.summary().blockCount(), loaded.mesh().quads().size()));
}
private void showFailure(long revision, String key, Throwable error) {
if (revision != objectRevision.get()) {
return;
}
statusBar.showActivity("");
viewport.showMessage("Could not load " + key);
overview.clear();
statusBar.showError(error.getMessage() == null ? error.getClass().getSimpleName() : error.getMessage());
}
// The pack's files may have changed, so the list and the shown object are read again.
private void reloadPack() {
loader.execute(() -> {
List<String> keys = objectKeys();
EventQueue.invokeLater(() -> showCatalog(keys));
});
}
private Engine engine() {
Engine current = GuiHost.findEngine(worldId);
return current != null && !current.isClosed() ? current : startEngine;
}
private BlockModel modelOf(BlockData block) {
return models.computeIfAbsent(block.getAsString(false), state -> {
BlockTextures available = textures;
if (available == null) {
return BlockModel.solidColor(UNKNOWN_BLOCK_COLOR);
}
return available.model(block).map(this::withBiomeTint).orElseGet(() -> BlockModel.solidColor(UNKNOWN_BLOCK_COLOR));
});
}
private BufferedImage iconOf(BlockData block) {
BlockTextures available = textures;
if (available == null) {
return null;
}
return available.iconTexture(block).map(this::withBiomeTint).map(BlockTexture::toImage).orElse(null);
}
// Objects have no biome of their own, so grass and leaves get plains' colours.
private BlockModel withBiomeTint(BlockModel model) {
if (model.isCross()) {
return BlockModel.cross(withBiomeTint(model.crossSprite()));
}
Map<CubeFace, BlockTexture> faces = new EnumMap<>(CubeFace.class);
model.faces().forEach((face, texture) -> faces.put(face, withBiomeTint(texture)));
return BlockModel.box(model.box(), faces);
}
private BlockTexture withBiomeTint(BlockTexture texture) {
BiomeTintColors colors = tintColors;
if (texture.biomeTint() == null || colors == null) {
return texture;
}
return texture.tinted(colors.colorAt(texture.biomeTint(), 0, 0));
}
private record LoadedObject(ObjectMesh mesh, ObjectSummary summary) {
}
}
@@ -0,0 +1,222 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.volmit.iris.core.gui;
import com.volmit.iris.core.gui.BlockTextures.BlockTexture;
import org.bukkit.block.data.BlockData;
import org.bukkit.util.BlockVector;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
/**
* The sides of an object's blocks that can be seen from outside, as flat textured quads. Sides pressed against a solid
* neighbour are left out, which is most of them in a large object.
*/
record ObjectMesh(List<Quad> quads, Bounds bounds) {
private static final double CROSS_SHADE = 0.9D;
static ObjectMesh build(Map<BlockVector, BlockData> blocks, Function<BlockData, BlockModel> modelOf) {
List<Quad> quads = new ArrayList<>();
for (Map.Entry<BlockVector, BlockData> entry : blocks.entrySet()) {
BlockVector position = entry.getKey();
BlockData block = entry.getValue();
BlockModel model = modelOf.apply(block);
if (model.isCross()) {
addCross(quads, position, model.crossSprite());
continue;
}
for (Map.Entry<CubeFace, BlockTexture> face : model.faces().entrySet()) {
if (!isCoveredByNeighbour(blocks, modelOf, position, block, model, face.getKey())) {
quads.add(Quad.side(position, model.box(), face.getKey(), face.getValue()));
}
}
}
return new ObjectMesh(List.copyOf(quads), Bounds.of(blocks.keySet()));
}
boolean isEmpty() {
return quads.isEmpty();
}
// Leaves next to leaves and glass next to glass hide the side between them too, as they do in the game.
private static boolean isCoveredByNeighbour(Map<BlockVector, BlockData> blocks, Function<BlockData, BlockModel> modelOf,
BlockVector position, BlockData block, BlockModel model, CubeFace face) {
if (!touchesBlockEdge(model.box(), face)) {
return false;
}
BlockData neighbour = blocks.get(new BlockVector(
position.getBlockX() + face.stepX(),
position.getBlockY() + face.stepY(),
position.getBlockZ() + face.stepZ()));
if (neighbour == null) {
return false;
}
if (neighbour.equals(block) && model.box().isFull()) {
return true;
}
return modelOf.apply(neighbour).hidesTouchingSides();
}
private static boolean touchesBlockEdge(BlockModel.Box box, CubeFace face) {
return switch (face) {
case UP -> box.maxY() >= 1D;
case DOWN -> box.minY() <= 0D;
case NORTH -> box.minZ() <= 0D;
case SOUTH -> box.maxZ() >= 1D;
case EAST -> box.maxX() >= 1D;
case WEST -> box.minX() <= 0D;
};
}
private static void addCross(List<Quad> quads, BlockVector position, BlockTexture sprite) {
double x = position.getBlockX();
double y = position.getBlockY();
double z = position.getBlockZ();
quads.add(new Quad(x, y + 1D, z, 1D, 0D, 1D, 0D, -1D, 0D, sprite, 0D, 0D, 1D, 1D, CROSS_SHADE, 0D, 0D, 0D));
quads.add(new Quad(x, y + 1D, z + 1D, 1D, 0D, -1D, 0D, -1D, 0D, sprite, 0D, 0D, 1D, 1D, CROSS_SHADE, 0D, 0D, 0D));
}
/**
* A parallelogram from its corner along two edges. Texture u runs along the first edge and v along the second,
* starting at {@code textureU}, {@code textureV} and covering {@code textureWidth} by {@code textureHeight} of
* the texture. A zero normal marks a quad seen from both sides, like a plant's sprite.
*/
record Quad(double originX, double originY, double originZ,
double edgeUX, double edgeUY, double edgeUZ,
double edgeVX, double edgeVY, double edgeVZ,
BlockTexture texture, double textureU, double textureV, double textureWidth, double textureHeight,
double shade, double normalX, double normalY, double normalZ) {
boolean isTwoSided() {
return normalX == 0D && normalY == 0D && normalZ == 0D;
}
// Each side is laid out as seen from outside the block: u to the right, v downwards, matching Minecraft's UVs.
static Quad side(BlockVector position, BlockModel.Box box, CubeFace face, BlockTexture texture) {
double[] origin = switch (face) {
case UP -> new double[]{box.minX(), box.maxY(), box.minZ()};
case DOWN -> new double[]{box.minX(), box.minY(), box.maxZ()};
case NORTH -> new double[]{box.maxX(), box.maxY(), box.minZ()};
case SOUTH -> new double[]{box.minX(), box.maxY(), box.maxZ()};
case EAST -> new double[]{box.maxX(), box.maxY(), box.maxZ()};
case WEST -> new double[]{box.minX(), box.maxY(), box.minZ()};
};
Edge u = switch (face) {
case UP, DOWN, SOUTH -> Edge.POSITIVE_X;
case NORTH -> Edge.NEGATIVE_X;
case EAST -> Edge.NEGATIVE_Z;
case WEST -> Edge.POSITIVE_Z;
};
Edge v = switch (face) {
case UP -> Edge.POSITIVE_Z;
case DOWN -> Edge.NEGATIVE_Z;
default -> Edge.NEGATIVE_Y;
};
double uLength = box.max(u.axis()) - box.min(u.axis());
double vLength = box.max(v.axis()) - box.min(v.axis());
return new Quad(
position.getBlockX() + origin[0], position.getBlockY() + origin[1], position.getBlockZ() + origin[2],
u.x() * uLength, u.y() * uLength, u.z() * uLength,
v.x() * vLength, v.y() * vLength, v.z() * vLength,
texture, u.textureStart(origin), v.textureStart(origin), uLength, vLength,
face.shade(), face.stepX(), face.stepY(), face.stepZ());
}
}
private enum Edge {
POSITIVE_X(0, 1),
NEGATIVE_X(0, -1),
NEGATIVE_Y(1, -1),
POSITIVE_Z(2, 1),
NEGATIVE_Z(2, -1);
private final int axis;
private final int sign;
Edge(int axis, int sign) {
this.axis = axis;
this.sign = sign;
}
int axis() {
return axis;
}
double x() {
return axis == 0 ? sign : 0D;
}
double y() {
return axis == 1 ? sign : 0D;
}
double z() {
return axis == 2 ? sign : 0D;
}
// An edge running against its axis starts its texture at the far side, like Minecraft's default UVs.
double textureStart(double[] origin) {
return sign > 0 ? origin[axis] : 1D - origin[axis];
}
}
/**
* The blocks' extent, with the maximum one past the last block so a single block spans one unit.
*/
record Bounds(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {
private static final Bounds EMPTY = new Bounds(0, 0, 0, 1, 1, 1);
static Bounds of(Iterable<BlockVector> positions) {
int minX = Integer.MAX_VALUE;
int minY = Integer.MAX_VALUE;
int minZ = Integer.MAX_VALUE;
int maxX = Integer.MIN_VALUE;
int maxY = Integer.MIN_VALUE;
int maxZ = Integer.MIN_VALUE;
for (BlockVector position : positions) {
minX = Math.min(minX, position.getBlockX());
minY = Math.min(minY, position.getBlockY());
minZ = Math.min(minZ, position.getBlockZ());
maxX = Math.max(maxX, position.getBlockX() + 1);
maxY = Math.max(maxY, position.getBlockY() + 1);
maxZ = Math.max(maxZ, position.getBlockZ() + 1);
}
return minX == Integer.MAX_VALUE ? EMPTY : new Bounds(minX, minY, minZ, maxX, maxY, maxZ);
}
int sizeX() {
return maxX - minX;
}
int sizeY() {
return maxY - minY;
}
int sizeZ() {
return maxZ - minZ;
}
int largestSize() {
return Math.max(sizeX(), Math.max(sizeY(), sizeZ()));
}
}
}
@@ -0,0 +1,259 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.volmit.iris.core.gui;
import javax.swing.BorderFactory;
import javax.swing.BoxLayout;
import javax.swing.DefaultListCellRenderer;
import javax.swing.DefaultListModel;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JPanel;
import javax.swing.JTextField;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.GridLayout;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.util.List;
import java.util.Locale;
/**
* The chosen object's size and the blocks it is made of, most used first, like the overview in Procedural Objects.
*/
final class ObjectOverviewPanel extends JPanel {
private static final long serialVersionUID = -8811407613645718290L;
private static final int PANEL_WIDTH = 280;
private static final int ROW_HEIGHT = 26;
private static final int ICON_SIZE = 16;
private static final int ICON_GAP = 8;
private static final int ROW_PADDING = 10;
private static final int SHARE_WIDTH = 92;
private static final int SHARE_BAR_HEIGHT = 4;
private final JLabel title = new JLabel(" ");
private final JLabel path = new JLabel(" ");
private final JLabel size = statValue();
private final JLabel blockCount = statValue();
private final JLabel blockTypes = statValue();
private final JLabel filled = statValue();
private final JTextField filterField = new JTextField();
private final DefaultListModel<ObjectSummary.BlockUse> rows = new DefaultListModel<>();
private final JList<ObjectSummary.BlockUse> list = new JList<>(rows) {
@Override
public boolean getScrollableTracksViewportWidth() {
return true;
}
};
private ObjectSummary summary;
ObjectOverviewPanel() {
super(new BorderLayout());
setPreferredSize(StudioTheme.scaledSize(PANEL_WIDTH, 0));
setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, StudioTheme.SEPARATOR));
setBackground(StudioTheme.PANEL_BACKGROUND);
add(buildHeader(), BorderLayout.NORTH);
add(buildBlockList(), BorderLayout.CENTER);
}
void show(ObjectSummary nextSummary) {
summary = nextSummary;
title.setText(nextSummary.name());
path.setText(nextSummary.key());
path.setToolTipText(nextSummary.key());
size.setText(nextSummary.sizeX() + " × " + nextSummary.sizeY() + " × " + nextSummary.sizeZ());
blockCount.setText(formatCount(nextSummary.blockCount()));
blockTypes.setText(formatCount(nextSummary.blockUses().size()));
filled.setText(String.format(Locale.ROOT, "%.1f%%", nextSummary.filledFraction() * 100D));
refilter();
}
void clear() {
summary = null;
title.setText(" ");
path.setText(" ");
for (JLabel value : List.of(size, blockCount, blockTypes, filled)) {
value.setText("—");
}
rows.clear();
}
private JPanel buildHeader() {
JPanel header = new JPanel();
header.setLayout(new BoxLayout(header, BoxLayout.Y_AXIS));
header.setOpaque(false);
header.setBorder(StudioTheme.emptyBorder(12, 16, 8, 16));
title.setFont(StudioTheme.titleFont());
path.setFont(StudioTheme.smallFont());
path.setForeground(StudioTheme.SECONDARY_TEXT);
header.add(leftAligned(title));
header.add(leftAligned(path));
JPanel stats = new JPanel(new GridLayout(2, 2, StudioTheme.scale(12), StudioTheme.scale(8)));
stats.setOpaque(false);
stats.setBorder(StudioTheme.emptyBorder(12, 0, 4, 0));
stats.add(stat("Size · X × Y × Z", size));
stats.add(stat("Blocks", blockCount));
stats.add(stat("Block types", blockTypes));
stats.add(stat("Filled volume", filled));
filled.setToolTipText("How much of the object's bounding box its blocks fill");
header.add(leftAligned(stats));
clear();
return header;
}
private JPanel buildBlockList() {
JPanel blocks = new JPanel(new BorderLayout());
blocks.setOpaque(false);
StudioToolbar.makeSearchField(filterField, "Find a block...");
filterField.getDocument().addDocumentListener(new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent event) {
refilter();
}
@Override
public void removeUpdate(DocumentEvent event) {
refilter();
}
@Override
public void changedUpdate(DocumentEvent event) {
refilter();
}
});
list.setFocusable(false);
list.setFixedCellHeight(StudioTheme.scale(ROW_HEIGHT));
list.setCellRenderer(new BlockUseRenderer());
list.setBackground(StudioTheme.PANEL_BACKGROUND);
list.setBorder(StudioTheme.emptyBorder(0, 6, 4, 6));
blocks.add(StudioToolbar.padded(filterField, 4, 12, 6, 12), BorderLayout.NORTH);
blocks.add(StudioToolbar.scroll(list), BorderLayout.CENTER);
return blocks;
}
private void refilter() {
rows.clear();
if (summary == null) {
return;
}
String filter = filterField.getText().trim().toLowerCase(Locale.ROOT).replace(' ', '_');
for (ObjectSummary.BlockUse use : summary.blockUses()) {
if (use.blockType().contains(filter)) {
rows.addElement(use);
}
}
}
private static JPanel stat(String label, JLabel value) {
JPanel stat = new JPanel(new BorderLayout());
stat.setOpaque(false);
JLabel name = new JLabel(label);
name.setFont(StudioTheme.smallFont());
name.setForeground(StudioTheme.MUTED_TEXT);
stat.add(name, BorderLayout.NORTH);
stat.add(value, BorderLayout.CENTER);
return stat;
}
private static JLabel statValue() {
JLabel value = new JLabel("—");
value.setFont(StudioTheme.boldFont());
return value;
}
private static Component leftAligned(JPanel panel) {
panel.setAlignmentX(LEFT_ALIGNMENT);
return panel;
}
private static Component leftAligned(JLabel label) {
label.setAlignmentX(LEFT_ALIGNMENT);
return label;
}
private static String formatCount(int count) {
return String.format(Locale.ROOT, "%,d", count);
}
private final class BlockUseRenderer extends DefaultListCellRenderer {
private static final long serialVersionUID = 2785337045812613941L;
private transient BufferedImage icon;
private double share;
private String percent = "";
@Override
public Component getListCellRendererComponent(JList<?> owner, Object value, int index, boolean selected, boolean focused) {
ObjectSummary.BlockUse use = (ObjectSummary.BlockUse) value;
super.getListCellRendererComponent(owner, use.blockType(), index, false, false);
setFont(StudioTheme.font());
setBorder(StudioTheme.emptyBorder(0, ROW_PADDING + ICON_SIZE + ICON_GAP, 0, ROW_PADDING + SHARE_WIDTH));
setToolTipText(use.blockType() + " · " + formatCount(use.count()) + " blocks");
icon = use.icon();
share = summary == null || summary.blockCount() == 0 ? 0D : use.count() / (double) summary.blockCount();
percent = share > 0D && share < 0.001D ? "<0.1%" : String.format(Locale.ROOT, "%.1f%%", share * 100D);
return this;
}
@Override
protected void paintComponent(Graphics graphics) {
super.paintComponent(graphics);
Graphics2D canvas = (Graphics2D) graphics.create();
try {
int iconSize = StudioTheme.scale(ICON_SIZE);
int iconX = StudioTheme.scale(ROW_PADDING);
int iconY = (getHeight() - iconSize) / 2;
if (icon != null) {
canvas.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
canvas.drawImage(icon, iconX, iconY, iconSize, iconSize, null);
} else {
canvas.setColor(StudioTheme.CONTROL_BORDER);
canvas.drawRect(iconX, iconY, iconSize - 1, iconSize - 1);
}
drawShare(canvas);
} finally {
canvas.dispose();
}
}
private void drawShare(Graphics2D canvas) {
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
canvas.setFont(StudioTheme.smallFont());
int padding = StudioTheme.scale(ROW_PADDING);
int percentWidth = canvas.getFontMetrics().stringWidth("100.0%");
int barWidth = StudioTheme.scale(SHARE_WIDTH) - percentWidth - StudioTheme.scale(6);
int barX = getWidth() - padding - StudioTheme.scale(SHARE_WIDTH);
int barHeight = StudioTheme.scale(SHARE_BAR_HEIGHT);
int barY = (getHeight() - barHeight) / 2;
canvas.setColor(StudioTheme.HOVER_BACKGROUND);
canvas.fillRoundRect(barX, barY, barWidth, barHeight, barHeight, barHeight);
canvas.setColor(StudioTheme.ACCENT);
canvas.fillRoundRect(barX, barY, Math.max(barHeight, (int) Math.round(barWidth * share)), barHeight, barHeight, barHeight);
canvas.setColor(StudioTheme.SECONDARY_TEXT);
int textWidth = canvas.getFontMetrics().stringWidth(percent);
int baseline = (getHeight() + canvas.getFontMetrics().getAscent() - canvas.getFontMetrics().getDescent()) / 2;
canvas.drawString(percent, getWidth() - padding - textWidth, baseline);
}
}
}
@@ -0,0 +1,252 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.volmit.iris.core.gui;
import com.volmit.iris.core.gui.BlockTextures.BlockTexture;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt;
import java.util.Arrays;
/**
* Draws an object mesh with an orthographic camera into a picture, without a graphics card: each quad is filled pixel
* by pixel, and a depth buffer keeps the nearest one.
*/
final class ObjectRenderer {
private static final int BACKGROUND = StudioTheme.EDITOR_BACKGROUND.getRGB() & 0xFFFFFF;
private static final int GRID_COLOR = 0x34373C;
private static final int GRID_LINES_PER_SIDE = 25;
private static final double GRID_REACH = 0.7D;
private static final double GRID_BELOW_OBJECT = 0.035D;
// Shared quad edges land exactly on pixel centres now and then; a little overlap keeps them from leaving cracks.
private static final double EDGE_TOLERANCE = 1e-6D;
private static final double EDGE_ON_DETERMINANT = 1e-9D;
private final int width;
private final int height;
private final int[] pixels;
private final double[] depths;
private final Projection projection;
private ObjectRenderer(ObjectMesh mesh, ObjectCamera camera, BufferedImage image, int coveredLeftWidth) {
this.width = image.getWidth();
this.height = image.getHeight();
this.pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
this.depths = new double[pixels.length];
this.projection = new Projection(mesh.bounds(), camera, width, height, coveredLeftWidth);
}
/**
* @param coveredLeftWidth how much of the picture's left side something floats over, so the object is centred and
* fitted in the rest
*/
static BufferedImage render(ObjectMesh mesh, ObjectCamera camera, int width, int height, int coveredLeftWidth) {
BufferedImage image = new BufferedImage(Math.max(1, width), Math.max(1, height), BufferedImage.TYPE_INT_RGB);
ObjectRenderer renderer = new ObjectRenderer(mesh, camera, image, Math.max(0, Math.min(width - 1, coveredLeftWidth)));
Arrays.fill(renderer.pixels, BACKGROUND);
Arrays.fill(renderer.depths, Double.NEGATIVE_INFINITY);
renderer.drawGrid(mesh.bounds());
for (ObjectMesh.Quad quad : mesh.quads()) {
renderer.drawQuad(quad);
}
return image;
}
private void drawGrid(ObjectMesh.Bounds bounds) {
int reach = (int) Math.ceil(bounds.largestSize() * GRID_REACH);
int step = Math.max(1, (int) Math.ceil(reach / (double) GRID_LINES_PER_SIDE));
double floor = bounds.minY() - GRID_BELOW_OBJECT;
int centerX = Math.floorDiv(bounds.minX() + bounds.maxX(), 2);
int centerZ = Math.floorDiv(bounds.minZ() + bounds.maxZ(), 2);
for (int offset = -reach; offset <= reach; offset += step) {
drawLine(centerX + offset, floor, centerZ - reach, centerX + offset, floor, centerZ + reach);
drawLine(centerX - reach, floor, centerZ + offset, centerX + reach, floor, centerZ + offset);
}
}
private void drawLine(double fromX, double fromY, double fromZ, double toX, double toY, double toZ) {
double startX = projection.screenX(fromX, fromY, fromZ);
double startY = projection.screenY(fromX, fromY, fromZ);
double startDepth = projection.depth(fromX, fromY, fromZ);
double endX = projection.screenX(toX, toY, toZ);
double endY = projection.screenY(toX, toY, toZ);
double endDepth = projection.depth(toX, toY, toZ);
int steps = (int) Math.ceil(Math.max(Math.abs(endX - startX), Math.abs(endY - startY)));
for (int step = 0; step <= steps; step++) {
double progress = steps == 0 ? 0D : step / (double) steps;
int x = (int) (startX + (endX - startX) * progress);
int y = (int) (startY + (endY - startY) * progress);
if (x < 0 || y < 0 || x >= width || y >= height) {
continue;
}
int index = y * width + x;
double depth = startDepth + (endDepth - startDepth) * progress;
if (depth > depths[index]) {
depths[index] = depth;
pixels[index] = GRID_COLOR;
}
}
}
private void drawQuad(ObjectMesh.Quad quad) {
if (!quad.isTwoSided() && !projection.facesCamera(quad.normalX(), quad.normalY(), quad.normalZ())) {
return;
}
double originX = projection.screenX(quad.originX(), quad.originY(), quad.originZ());
double originY = projection.screenY(quad.originX(), quad.originY(), quad.originZ());
double originDepth = projection.depth(quad.originX(), quad.originY(), quad.originZ());
double edgeUX = projection.screenStepX(quad.edgeUX(), quad.edgeUZ());
double edgeUY = projection.screenStepY(quad.edgeUX(), quad.edgeUY(), quad.edgeUZ());
double edgeUDepth = projection.depthStep(quad.edgeUX(), quad.edgeUY(), quad.edgeUZ());
double edgeVX = projection.screenStepX(quad.edgeVX(), quad.edgeVZ());
double edgeVY = projection.screenStepY(quad.edgeVX(), quad.edgeVY(), quad.edgeVZ());
double edgeVDepth = projection.depthStep(quad.edgeVX(), quad.edgeVY(), quad.edgeVZ());
double determinant = edgeUX * edgeVY - edgeUY * edgeVX;
if (Math.abs(determinant) < EDGE_ON_DETERMINANT) {
return;
}
int left = Math.max(0, (int) Math.floor(min4(originX, originX + edgeUX, originX + edgeVX, originX + edgeUX + edgeVX)));
int right = Math.min(width - 1, (int) Math.ceil(max4(originX, originX + edgeUX, originX + edgeVX, originX + edgeUX + edgeVX)));
int top = Math.max(0, (int) Math.floor(min4(originY, originY + edgeUY, originY + edgeVY, originY + edgeUY + edgeVY)));
int bottom = Math.min(height - 1, (int) Math.ceil(max4(originY, originY + edgeUY, originY + edgeVY, originY + edgeUY + edgeVY)));
if (left > right || top > bottom) {
return;
}
BlockTexture texture = quad.texture();
int textureSize = texture.size();
int[] texels = texture.pixels();
boolean[] opaque = texture.opaque();
double alongUPerPixelX = edgeVY / determinant;
double alongVPerPixelX = -edgeUY / determinant;
for (int y = top; y <= bottom; y++) {
double offsetX = left + 0.5D - originX;
double offsetY = y + 0.5D - originY;
double alongU = (offsetX * edgeVY - offsetY * edgeVX) / determinant;
double alongV = (offsetY * edgeUX - offsetX * edgeUY) / determinant;
int rowStart = y * width;
for (int x = left; x <= right; x++, alongU += alongUPerPixelX, alongV += alongVPerPixelX) {
if (alongU < -EDGE_TOLERANCE || alongU > 1D + EDGE_TOLERANCE || alongV < -EDGE_TOLERANCE || alongV > 1D + EDGE_TOLERANCE) {
continue;
}
int index = rowStart + x;
double depth = originDepth + alongU * edgeUDepth + alongV * edgeVDepth;
if (depth <= depths[index]) {
continue;
}
int texelX = texelCoordinate(quad.textureU() + alongU * quad.textureWidth(), textureSize);
int texelY = texelCoordinate(quad.textureV() + alongV * quad.textureHeight(), textureSize);
int texel = texelY * textureSize + texelX;
if (!opaque[texel]) {
continue;
}
depths[index] = depth;
pixels[index] = BlockPreviewColors.shade(texels[texel], quad.shade());
}
}
}
private static int texelCoordinate(double fraction, int textureSize) {
return Math.max(0, Math.min(textureSize - 1, (int) (fraction * textureSize)));
}
private static double min4(double a, double b, double c, double d) {
return Math.min(Math.min(a, b), Math.min(c, d));
}
private static double max4(double a, double b, double c, double d) {
return Math.max(Math.max(a, b), Math.max(c, d));
}
/**
* Turns block positions into pixels. Larger depth is nearer the camera.
*/
private static final class Projection {
private final double centerX;
private final double centerY;
private final double centerZ;
private final double cosYaw;
private final double sinYaw;
private final double cosPitch;
private final double sinPitch;
private final double pixelsPerBlock;
private final double screenCenterX;
private final double screenCenterY;
private Projection(ObjectMesh.Bounds bounds, ObjectCamera camera, int width, int height, int coveredLeftWidth) {
this.centerX = (bounds.minX() + bounds.maxX()) / 2D;
this.centerY = (bounds.minY() + bounds.maxY()) / 2D;
this.centerZ = (bounds.minZ() + bounds.maxZ()) / 2D;
this.cosYaw = Math.cos(camera.yaw());
this.sinYaw = Math.sin(camera.yaw());
this.cosPitch = Math.cos(camera.pitch());
this.sinPitch = Math.sin(camera.pitch());
int visibleWidth = width - coveredLeftWidth;
double fit = fitAtStartingAngle(bounds, visibleWidth / (double) height);
this.pixelsPerBlock = camera.zoom() * fit * height / 2D / bounds.largestSize();
this.screenCenterX = coveredLeftWidth + visibleWidth / 2D + camera.panX() * visibleWidth / 2D;
this.screenCenterY = height / 2D - camera.panY() * height / 2D;
}
// How far to scale the object down so it fits the view at the starting angle; orbiting keeps the scale.
private static double fitAtStartingAngle(ObjectMesh.Bounds bounds, double aspect) {
double size = bounds.largestSize();
double width = bounds.sizeX() / size;
double height = bounds.sizeY() / size;
double depth = bounds.sizeZ() / size;
double yaw = ObjectCamera.START.yaw();
double pitch = ObjectCamera.START.pitch();
double projectedWidth = Math.abs(Math.cos(yaw)) * width + Math.abs(Math.sin(yaw)) * depth;
double projectedDepth = Math.abs(Math.sin(yaw)) * width + Math.abs(Math.cos(yaw)) * depth;
double projectedHeight = Math.abs(Math.cos(pitch)) * height + Math.abs(Math.sin(pitch)) * projectedDepth;
return Math.min(1D, Math.min(aspect / projectedWidth, 1D / projectedHeight));
}
double screenX(double x, double y, double z) {
return screenCenterX + screenStepX(x - centerX, z - centerZ);
}
double screenY(double x, double y, double z) {
return screenCenterY + screenStepY(x - centerX, y - centerY, z - centerZ);
}
double depth(double x, double y, double z) {
return depthStep(x - centerX, y - centerY, z - centerZ);
}
double screenStepX(double x, double z) {
return (cosYaw * x - sinYaw * z) * pixelsPerBlock;
}
double screenStepY(double x, double y, double z) {
double towardCamera = sinYaw * x + cosYaw * z;
return -(cosPitch * y - sinPitch * towardCamera) * pixelsPerBlock;
}
double depthStep(double x, double y, double z) {
double towardCamera = sinYaw * x + cosYaw * z;
return sinPitch * y + cosPitch * towardCamera;
}
boolean facesCamera(double normalX, double normalY, double normalZ) {
return depthStep(normalX, normalY, normalZ) > 0D;
}
}
}
@@ -0,0 +1,65 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.volmit.iris.core.gui;
import org.bukkit.block.data.BlockData;
import java.awt.image.BufferedImage;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
/**
* What an object is made of: its size and how many of each block type it uses, most used first.
*/
record ObjectSummary(String key, int sizeX, int sizeY, int sizeZ, int blockCount, List<BlockUse> blockUses) {
static ObjectSummary of(String key, ObjectMesh.Bounds bounds, Iterable<BlockData> blocks, Function<BlockData, BufferedImage> iconOf) {
Map<String, Integer> counts = new HashMap<>();
Map<String, BlockData> examples = new HashMap<>();
int blockCount = 0;
for (BlockData block : blocks) {
String blockType = block.getMaterial().getKey().getKey();
counts.merge(blockType, 1, Integer::sum);
examples.putIfAbsent(blockType, block);
blockCount++;
}
List<BlockUse> uses = counts.entrySet().stream()
.map(count -> new BlockUse(count.getKey(), count.getValue(), iconOf.apply(examples.get(count.getKey()))))
.sorted(Comparator.comparingInt(BlockUse::count).reversed().thenComparing(BlockUse::blockType))
.toList();
return new ObjectSummary(key, bounds.sizeX(), bounds.sizeY(), bounds.sizeZ(), blockCount, uses);
}
String name() {
return key.substring(key.lastIndexOf('/') + 1);
}
double filledFraction() {
long volume = (long) sizeX * sizeY * sizeZ;
return volume == 0 ? 0D : blockCount / (double) volume;
}
/**
* @param icon the block's texture, or null when Minecraft's textures are not available
*/
record BlockUse(String blockType, int count, BufferedImage icon) {
}
}
@@ -0,0 +1,229 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.volmit.iris.core.gui;
import javax.swing.AbstractAction;
import javax.swing.JComponent;
import javax.swing.KeyStroke;
import javax.swing.SwingUtilities;
import java.awt.EventQueue;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.RenderingHints;
import java.awt.event.ActionEvent;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.KeyEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseWheelEvent;
import java.awt.image.BufferedImage;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.UnaryOperator;
/**
* The 3D view of one object. Drag to orbit, shift-drag or middle-drag to pan, scroll to zoom; arrow keys orbit and
* plus and minus zoom too. Frames are drawn on a background thread, one at a time, always for the latest camera.
*/
final class ObjectViewport extends JComponent {
private static final long serialVersionUID = -3175226517409913524L;
private static final double WHEEL_ZOOM_EXPONENT = 0.12D;
private static final double KEY_ZOOM_FACTOR = 1.1D;
private static final int KEY_ORBIT_PIXELS = 12;
private final ExecutorService renderThread = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "Iris Object Viewer");
thread.setDaemon(true);
return thread;
});
private ObjectMesh mesh;
private ObjectCamera camera = ObjectCamera.START;
private BufferedImage frame;
private String message = "Choose an object";
private boolean rendering;
private boolean renderAgain;
private Point dragPoint;
private int coveredLeftWidth;
ObjectViewport() {
setOpaque(true);
setBackground(StudioTheme.EDITOR_BACKGROUND);
setFocusable(true);
installMouseControls();
installKeyControls();
addComponentListener(new ComponentAdapter() {
@Override
public void componentResized(ComponentEvent event) {
requestFrame();
}
});
}
void show(ObjectMesh nextMesh) {
mesh = nextMesh;
camera = ObjectCamera.START;
message = nextMesh.isEmpty() ? "No visible blocks" : null;
frame = null;
requestFrame();
repaint();
}
void showMessage(String text) {
mesh = null;
message = text;
frame = null;
repaint();
}
/**
* Keeps the object clear of a panel floating over the viewport's left side.
*/
void setCoveredLeftWidth(int width) {
if (width == coveredLeftWidth) {
return;
}
coveredLeftWidth = width;
requestFrame();
}
void resetCamera() {
moveCamera(current -> ObjectCamera.START);
}
void close() {
renderThread.shutdownNow();
}
private void moveCamera(UnaryOperator<ObjectCamera> move) {
camera = move.apply(camera);
requestFrame();
}
private void requestFrame() {
if (mesh == null || mesh.isEmpty() || getWidth() < 1 || getHeight() < 1) {
return;
}
if (rendering) {
renderAgain = true;
return;
}
rendering = true;
ObjectMesh frameMesh = mesh;
ObjectCamera frameCamera = camera;
int width = getWidth();
int height = getHeight();
int covered = coveredLeftWidth;
renderThread.execute(() -> {
BufferedImage image = ObjectRenderer.render(frameMesh, frameCamera, width, height, covered);
EventQueue.invokeLater(() -> finishFrame(frameMesh, image));
});
}
private void finishFrame(ObjectMesh frameMesh, BufferedImage image) {
rendering = false;
if (frameMesh == mesh) {
frame = image;
repaint();
}
if (renderAgain) {
renderAgain = false;
requestFrame();
}
}
@Override
protected void paintComponent(Graphics graphics) {
graphics.setColor(getBackground());
graphics.fillRect(0, 0, getWidth(), getHeight());
if (frame != null) {
graphics.drawImage(frame, 0, 0, null);
}
if (message != null) {
drawMessage((Graphics2D) graphics);
}
}
private void drawMessage(Graphics2D canvas) {
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
canvas.setFont(StudioTheme.font());
canvas.setColor(StudioTheme.MUTED_TEXT);
int textWidth = canvas.getFontMetrics().stringWidth(message);
canvas.drawString(message, (getWidth() - textWidth) / 2, getHeight() / 2);
}
private void installMouseControls() {
MouseAdapter mouse = new MouseAdapter() {
@Override
public void mousePressed(MouseEvent event) {
dragPoint = event.getPoint();
requestFocusInWindow();
}
@Override
public void mouseReleased(MouseEvent event) {
dragPoint = null;
}
@Override
public void mouseDragged(MouseEvent event) {
if (dragPoint == null) {
return;
}
int draggedX = event.getX() - dragPoint.x;
int draggedY = event.getY() - dragPoint.y;
dragPoint = event.getPoint();
boolean panning = event.isShiftDown() || SwingUtilities.isMiddleMouseButton(event);
moveCamera(current -> panning
? current.pannedBy(2D * draggedX / Math.max(1, getWidth() - coveredLeftWidth), -2D * draggedY / getHeight())
: current.orbitedBy(draggedX, draggedY));
}
@Override
public void mouseWheelMoved(MouseWheelEvent event) {
moveCamera(current -> current.zoomedBy(Math.exp(-event.getPreciseWheelRotation() * WHEEL_ZOOM_EXPONENT)));
}
};
addMouseListener(mouse);
addMouseMotionListener(mouse);
addMouseWheelListener(mouse);
}
private void installKeyControls() {
bindKey(KeyEvent.VK_LEFT, "orbit-left", current -> current.orbitedBy(-KEY_ORBIT_PIXELS, 0));
bindKey(KeyEvent.VK_RIGHT, "orbit-right", current -> current.orbitedBy(KEY_ORBIT_PIXELS, 0));
bindKey(KeyEvent.VK_UP, "orbit-up", current -> current.orbitedBy(0, KEY_ORBIT_PIXELS));
bindKey(KeyEvent.VK_DOWN, "orbit-down", current -> current.orbitedBy(0, -KEY_ORBIT_PIXELS));
bindKey(KeyEvent.VK_EQUALS, "zoom-in", current -> current.zoomedBy(KEY_ZOOM_FACTOR));
bindKey(KeyEvent.VK_ADD, "zoom-in-keypad", current -> current.zoomedBy(KEY_ZOOM_FACTOR));
bindKey(KeyEvent.VK_MINUS, "zoom-out", current -> current.zoomedBy(1D / KEY_ZOOM_FACTOR));
bindKey(KeyEvent.VK_SUBTRACT, "zoom-out-keypad", current -> current.zoomedBy(1D / KEY_ZOOM_FACTOR));
}
private void bindKey(int keyCode, String name, UnaryOperator<ObjectCamera> move) {
getInputMap(WHEN_FOCUSED).put(KeyStroke.getKeyStroke(keyCode, 0), name);
getActionMap().put(name, new AbstractAction() {
@Override
public void actionPerformed(ActionEvent event) {
moveCamera(move);
}
});
}
}