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); + } + }); + } +}