diff --git a/core/src/main/java/com/volmit/iris/core/gui/BlockModel.java b/core/src/main/java/com/volmit/iris/core/gui/BlockModel.java
new file mode 100644
index 000000000..a6dcc8e83
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/BlockModel.java
@@ -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 .
+ */
+
+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 faces, BlockTexture crossSprite) {
+ static BlockModel box(Box box, Map 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 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
+ };
+ }
+ }
+}
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 9bf7212c7..b83f6830c 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
@@ -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> resolvedTextures = new ConcurrentHashMap<>();
+ private final Map> 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 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 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 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 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 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 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 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 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 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) {
}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/CubeFace.java b/core/src/main/java/com/volmit/iris/core/gui/CubeFace.java
new file mode 100644
index 000000000..d319a3bf3
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/CubeFace.java
@@ -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 .
+ */
+
+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);
+ }
+}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapView.java b/core/src/main/java/com/volmit/iris/core/gui/MapView.java
index 654577a64..f5f192430 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/MapView.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/MapView.java
@@ -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;
diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapWindow.java b/core/src/main/java/com/volmit/iris/core/gui/MapWindow.java
index 5dc367408..b994e135c 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/MapWindow.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/MapWindow.java
@@ -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();
}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/ObjectBrowser.java b/core/src/main/java/com/volmit/iris/core/gui/ObjectBrowser.java
new file mode 100644
index 000000000..77c897cc4
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectBrowser.java
@@ -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 .
+ */
+
+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 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 objectKeys = List.of();
+ private String chosenKey;
+ private boolean updatingSelection;
+
+ ObjectBrowser(Consumer 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 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 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 subfolders = new LinkedHashMap<>();
+ private final List 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;
+ }
+ }
+}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/ObjectCamera.java b/core/src/main/java/com/volmit/iris/core/gui/ObjectCamera.java
new file mode 100644
index 000000000..948919690
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectCamera.java
@@ -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 .
+ */
+
+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);
+ }
+}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/ObjectExplorer.java b/core/src/main/java/com/volmit/iris/core/gui/ObjectExplorer.java
new file mode 100644
index 000000000..945d878c0
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectExplorer.java
@@ -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 .
+ */
+
+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 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 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 objectKeys() {
+ return Arrays.asList(engine().getData().getObjectLoader().getPossibleKeys());
+ }
+
+ private void showCatalog(List 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 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 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 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) {
+ }
+}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/ObjectMesh.java b/core/src/main/java/com/volmit/iris/core/gui/ObjectMesh.java
new file mode 100644
index 000000000..287231756
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectMesh.java
@@ -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 .
+ */
+
+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 quads, Bounds bounds) {
+ private static final double CROSS_SHADE = 0.9D;
+
+ static ObjectMesh build(Map blocks, Function modelOf) {
+ List quads = new ArrayList<>();
+ for (Map.Entry 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 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 blocks, Function 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 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 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()));
+ }
+ }
+}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/ObjectOverviewPanel.java b/core/src/main/java/com/volmit/iris/core/gui/ObjectOverviewPanel.java
new file mode 100644
index 000000000..107cbe10f
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectOverviewPanel.java
@@ -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 .
+ */
+
+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 rows = new DefaultListModel<>();
+ private final JList 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);
+ }
+ }
+}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/ObjectRenderer.java b/core/src/main/java/com/volmit/iris/core/gui/ObjectRenderer.java
new file mode 100644
index 000000000..61b118bcb
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectRenderer.java
@@ -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 .
+ */
+
+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;
+ }
+ }
+}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/ObjectSummary.java b/core/src/main/java/com/volmit/iris/core/gui/ObjectSummary.java
new file mode 100644
index 000000000..f6a25651f
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectSummary.java
@@ -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 .
+ */
+
+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 blockUses) {
+ static ObjectSummary of(String key, ObjectMesh.Bounds bounds, Iterable blocks, Function iconOf) {
+ Map counts = new HashMap<>();
+ Map 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 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) {
+ }
+}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/ObjectViewport.java b/core/src/main/java/com/volmit/iris/core/gui/ObjectViewport.java
new file mode 100644
index 000000000..ba99a6b80
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectViewport.java
@@ -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 .
+ */
+
+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 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 move) {
+ getInputMap(WHEN_FOCUSED).put(KeyStroke.getKeyStroke(keyCode, 0), name);
+ getActionMap().put(name, new AbstractAction() {
+ @Override
+ public void actionPerformed(ActionEvent event) {
+ moveCamera(move);
+ }
+ });
+ }
+}