mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-27 06:20:02 +00:00
Pin folder headers, cut layers and see through blocks in the Objects view
Folder headers stay pinned at the top of the object tree while scrolling, one per nesting level; clicking one jumps to that folder. A faint slider over the 3D view (or Page Up/Down) cuts away the top layers so floors of large structures like the woodland mansion can be looked into. Clicking a block in the overview list makes that block type see-through, so whatever it hides shows behind it. Model used: Claude Opus 5.5
This commit is contained in:
1 parent
db3d42df26
commit
99258bfd98
9 files changed
+674
-29
No files matched your search
@@ -270,7 +270,7 @@ public final class MapWindow implements MapCanvas.Listener {
|
|||||||
viewport.setCoveredLeftWidth(sidebarIsland.getWidth());
|
viewport.setCoveredLeftWidth(sidebarIsland.getWidth());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
content.add(viewport, OBJECTS_CARD);
|
content.add(objectExplorer.view(), OBJECTS_CARD);
|
||||||
statusBars.add(objectExplorer.statusBar(), OBJECTS_CARD);
|
statusBars.add(objectExplorer.statusBar(), OBJECTS_CARD);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ final class ObjectBrowser extends JPanel {
|
|||||||
countLabel.setFont(StudioTheme.smallFont());
|
countLabel.setFont(StudioTheme.smallFont());
|
||||||
countLabel.setForeground(StudioTheme.SECONDARY_TEXT);
|
countLabel.setForeground(StudioTheme.SECONDARY_TEXT);
|
||||||
add(StudioToolbar.padded(searchField, 8, 8, 8, 8), BorderLayout.NORTH);
|
add(StudioToolbar.padded(searchField, 8, 8, 8, 8), BorderLayout.NORTH);
|
||||||
add(StudioToolbar.scroll(tree), BorderLayout.CENTER);
|
add(StickyFolderHeaders.around(tree, StudioToolbar.scroll(tree)), BorderLayout.CENTER);
|
||||||
add(StudioToolbar.padded(countLabel, 6, 12, 8, 12), BorderLayout.SOUTH);
|
add(StudioToolbar.padded(countLabel, 6, 12, 8, 12), BorderLayout.SOUTH);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,12 +27,14 @@ import org.bukkit.util.BlockVector;
|
|||||||
|
|
||||||
import javax.swing.JComponent;
|
import javax.swing.JComponent;
|
||||||
import java.awt.EventQueue;
|
import java.awt.EventQueue;
|
||||||
|
import java.awt.event.KeyEvent;
|
||||||
import java.awt.image.BufferedImage;
|
import java.awt.image.BufferedImage;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
@@ -46,13 +48,15 @@ import java.util.concurrent.atomic.AtomicLong;
|
|||||||
final class ObjectExplorer {
|
final class ObjectExplorer {
|
||||||
private static final NamespacedKey TINT_BIOME = NamespacedKey.minecraft("plains");
|
private static final NamespacedKey TINT_BIOME = NamespacedKey.minecraft("plains");
|
||||||
private static final int UNKNOWN_BLOCK_COLOR = 0x8A8A8A;
|
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 static final String CAMERA_HINT = "Drag to orbit · Shift-drag to pan · Scroll to zoom · Page Up/Down cuts layers · R resets";
|
||||||
|
|
||||||
private final Engine startEngine;
|
private final Engine startEngine;
|
||||||
private final UUID worldId;
|
private final UUID worldId;
|
||||||
private final ObjectBrowser browser = new ObjectBrowser(this::showObject);
|
private final ObjectBrowser browser = new ObjectBrowser(this::showObject);
|
||||||
private final ObjectViewport viewport = new ObjectViewport();
|
private final ObjectViewport viewport = new ObjectViewport();
|
||||||
private final ObjectOverviewPanel overview = new ObjectOverviewPanel();
|
private final ObjectLayerCut layerCut = new ObjectLayerCut(shownLayers -> rebuildShownMesh());
|
||||||
|
private final JComponent viewWithLayerCut = layerCut.floatingOver(viewport);
|
||||||
|
private final ObjectOverviewPanel overview = new ObjectOverviewPanel(this::showSeeThrough);
|
||||||
private final StudioStatusBar statusBar = new StudioStatusBar();
|
private final StudioStatusBar statusBar = new StudioStatusBar();
|
||||||
private final ExecutorService loader = Executors.newSingleThreadExecutor(runnable -> {
|
private final ExecutorService loader = Executors.newSingleThreadExecutor(runnable -> {
|
||||||
Thread thread = new Thread(runnable, "Iris Object Loader");
|
Thread thread = new Thread(runnable, "Iris Object Loader");
|
||||||
@@ -61,11 +65,14 @@ final class ObjectExplorer {
|
|||||||
return thread;
|
return thread;
|
||||||
});
|
});
|
||||||
private final AtomicLong objectRevision = new AtomicLong();
|
private final AtomicLong objectRevision = new AtomicLong();
|
||||||
|
private final AtomicLong meshRevision = new AtomicLong();
|
||||||
private final Map<String, BlockModel> models = new ConcurrentHashMap<>();
|
private final Map<String, BlockModel> models = new ConcurrentHashMap<>();
|
||||||
private final Runnable hotloadHook = this::reloadPack;
|
private final Runnable hotloadHook = this::reloadPack;
|
||||||
private volatile BlockTextures textures;
|
private volatile BlockTextures textures;
|
||||||
private volatile BiomeTintColors tintColors;
|
private volatile BiomeTintColors tintColors;
|
||||||
private String shownKey;
|
private String shownKey;
|
||||||
|
private LoadedObject shown;
|
||||||
|
private Set<String> seeThroughTypes = Set.of();
|
||||||
|
|
||||||
ObjectExplorer(Engine engine, UUID worldId) {
|
ObjectExplorer(Engine engine, UUID worldId) {
|
||||||
this.startEngine = engine;
|
this.startEngine = engine;
|
||||||
@@ -73,6 +80,8 @@ final class ObjectExplorer {
|
|||||||
GuiHost.interruptOnPluginShutdown(loader);
|
GuiHost.interruptOnPluginShutdown(loader);
|
||||||
GuiHost.registerHotloadHook(hotloadHook);
|
GuiHost.registerHotloadHook(hotloadHook);
|
||||||
statusBar.showPosition(CAMERA_HINT);
|
statusBar.showPosition(CAMERA_HINT);
|
||||||
|
viewport.bindKey(KeyEvent.VK_PAGE_UP, "show-layer-above", () -> layerCut.stepBy(1));
|
||||||
|
viewport.bindKey(KeyEvent.VK_PAGE_DOWN, "hide-top-layer", () -> layerCut.stepBy(-1));
|
||||||
}
|
}
|
||||||
|
|
||||||
JComponent browser() {
|
JComponent browser() {
|
||||||
@@ -83,6 +92,13 @@ final class ObjectExplorer {
|
|||||||
return viewport;
|
return viewport;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 3D view with the slider that cuts away its top layers.
|
||||||
|
*/
|
||||||
|
JComponent view() {
|
||||||
|
return viewWithLayerCut;
|
||||||
|
}
|
||||||
|
|
||||||
JComponent overview() {
|
JComponent overview() {
|
||||||
return overview;
|
return overview;
|
||||||
}
|
}
|
||||||
@@ -133,6 +149,7 @@ final class ObjectExplorer {
|
|||||||
if (key == null) {
|
if (key == null) {
|
||||||
viewport.showMessage("This pack has no objects");
|
viewport.showMessage("This pack has no objects");
|
||||||
overview.clear();
|
overview.clear();
|
||||||
|
layerCut.showHeight(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
browser.select(key);
|
browser.select(key);
|
||||||
@@ -141,6 +158,8 @@ final class ObjectExplorer {
|
|||||||
|
|
||||||
private void showObject(String key) {
|
private void showObject(String key) {
|
||||||
shownKey = key;
|
shownKey = key;
|
||||||
|
shown = null;
|
||||||
|
meshRevision.incrementAndGet();
|
||||||
long revision = objectRevision.incrementAndGet();
|
long revision = objectRevision.incrementAndGet();
|
||||||
viewport.showMessage("Loading " + key + "...");
|
viewport.showMessage("Loading " + key + "...");
|
||||||
statusBar.showActivity("Loading " + key + "...");
|
statusBar.showActivity("Loading " + key + "...");
|
||||||
@@ -170,18 +189,73 @@ final class ObjectExplorer {
|
|||||||
});
|
});
|
||||||
ObjectMesh mesh = ObjectMesh.build(blocks, this::modelOf);
|
ObjectMesh mesh = ObjectMesh.build(blocks, this::modelOf);
|
||||||
ObjectSummary summary = ObjectSummary.of(key, mesh.bounds(), blocks.values(), this::iconOf);
|
ObjectSummary summary = ObjectSummary.of(key, mesh.bounds(), blocks.values(), this::iconOf);
|
||||||
return new LoadedObject(mesh, summary);
|
return new LoadedObject(blocks, mesh, summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void showLoaded(long revision, LoadedObject loaded) {
|
private void showLoaded(long revision, LoadedObject loaded) {
|
||||||
if (revision != objectRevision.get()) {
|
if (revision != objectRevision.get()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
shown = loaded;
|
||||||
|
seeThroughTypes = Set.of();
|
||||||
statusBar.showActivity("");
|
statusBar.showActivity("");
|
||||||
viewport.show(loaded.mesh());
|
viewport.show(loaded.mesh());
|
||||||
overview.show(loaded.summary());
|
overview.show(loaded.summary());
|
||||||
statusBar.showSummary(String.format(Locale.ROOT, "%s · %,d blocks · %,d faces drawn",
|
layerCut.showHeight(loaded.mesh().bounds().sizeY());
|
||||||
loaded.summary().key(), loaded.summary().blockCount(), loaded.mesh().faceCount()));
|
showMeshSummary(loaded.mesh());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showMeshSummary(ObjectMesh mesh) {
|
||||||
|
ObjectSummary summary = shown.summary();
|
||||||
|
String cut = layerCut.isCutting()
|
||||||
|
? String.format(Locale.ROOT, " · showing layers 1-%d of %d", layerCut.shownLayerCount(), layerCut.layerCount())
|
||||||
|
: "";
|
||||||
|
statusBar.showSummary(String.format(Locale.ROOT, "%s · %,d blocks · %,d faces drawn%s",
|
||||||
|
summary.key(), summary.blockCount(), mesh.faceCount(), cut));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showSeeThrough(Set<String> blockTypes) {
|
||||||
|
seeThroughTypes = blockTypes;
|
||||||
|
rebuildShownMesh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blocks under the cut that were covered by the ones above get their hidden faces back, so each floor shows, and
|
||||||
|
// blocks behind see-through ones get theirs back too.
|
||||||
|
private void rebuildShownMesh() {
|
||||||
|
LoadedObject object = shown;
|
||||||
|
if (object == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long revision = meshRevision.incrementAndGet();
|
||||||
|
ObjectMesh.Bounds bounds = object.mesh().bounds();
|
||||||
|
boolean cutting = layerCut.isCutting();
|
||||||
|
int highestShownY = bounds.minY() + layerCut.shownLayerCount() - 1;
|
||||||
|
Set<String> seeThrough = seeThroughTypes;
|
||||||
|
loader.execute(() -> {
|
||||||
|
if (revision != meshRevision.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ObjectMesh mesh = !cutting && seeThrough.isEmpty()
|
||||||
|
? object.mesh()
|
||||||
|
: ObjectMesh.build(cutting ? blocksUpTo(object.blocks(), highestShownY) : object.blocks(), this::modelOf, bounds,
|
||||||
|
block -> seeThrough.contains(block.getMaterial().getKey().getKey()));
|
||||||
|
EventQueue.invokeLater(() -> {
|
||||||
|
if (revision == meshRevision.get() && shown == object) {
|
||||||
|
viewport.replaceMesh(mesh);
|
||||||
|
showMeshSummary(mesh);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<BlockVector, BlockData> blocksUpTo(Map<BlockVector, BlockData> blocks, int highestY) {
|
||||||
|
Map<BlockVector, BlockData> below = new HashMap<>();
|
||||||
|
blocks.forEach((position, block) -> {
|
||||||
|
if (position.getBlockY() <= highestY) {
|
||||||
|
below.put(position, block);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return below;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void showFailure(long revision, String key, Throwable error) {
|
private void showFailure(long revision, String key, Throwable error) {
|
||||||
@@ -191,6 +265,7 @@ final class ObjectExplorer {
|
|||||||
statusBar.showActivity("");
|
statusBar.showActivity("");
|
||||||
viewport.showMessage("Could not load " + key);
|
viewport.showMessage("Could not load " + key);
|
||||||
overview.clear();
|
overview.clear();
|
||||||
|
layerCut.showHeight(1);
|
||||||
statusBar.showError(error.getMessage() == null ? error.getClass().getSimpleName() : error.getMessage());
|
statusBar.showError(error.getMessage() == null ? error.getClass().getSimpleName() : error.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +313,6 @@ final class ObjectExplorer {
|
|||||||
return texture.tinted(colors.colorAt(texture.biomeTint(), 0, 0));
|
return texture.tinted(colors.colorAt(texture.biomeTint(), 0, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
private record LoadedObject(ObjectMesh mesh, ObjectSummary summary) {
|
private record LoadedObject(Map<BlockVector, BlockData> blocks, ObjectMesh mesh, ObjectSummary summary) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/*
|
||||||
|
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||||
|
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
package com.volmit.iris.core.gui;
|
||||||
|
|
||||||
|
import javax.swing.JComponent;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JSlider;
|
||||||
|
import javax.swing.SwingConstants;
|
||||||
|
import java.awt.AlphaComposite;
|
||||||
|
import java.awt.BorderLayout;
|
||||||
|
import java.awt.Graphics;
|
||||||
|
import java.awt.Graphics2D;
|
||||||
|
import java.awt.event.MouseAdapter;
|
||||||
|
import java.awt.event.MouseEvent;
|
||||||
|
import java.util.function.IntConsumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A slim slider floating on the right of the 3D view that hides an object's top layers, to look inside buildings
|
||||||
|
* floor by floor. Most objects never need it, so it stays faint until the mouse comes near or a cut is active.
|
||||||
|
*/
|
||||||
|
final class ObjectLayerCut extends JPanel {
|
||||||
|
private static final long serialVersionUID = 4473820211915526120L;
|
||||||
|
private static final int PANEL_WIDTH = 30;
|
||||||
|
private static final int MAX_HEIGHT = 320;
|
||||||
|
private static final double HEIGHT_SHARE_OF_VIEW = 0.5D;
|
||||||
|
private static final int EDGE_MARGIN = 8;
|
||||||
|
private static final float FAINT_OPACITY = 0.28F;
|
||||||
|
|
||||||
|
private final JSlider slider = new JSlider(SwingConstants.VERTICAL, 1, 1, 1);
|
||||||
|
private final JLabel shownLayers = new JLabel(" ", SwingConstants.CENTER);
|
||||||
|
private final IntConsumer onLayersChanged;
|
||||||
|
private boolean resetting;
|
||||||
|
private boolean hovered;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param onLayersChanged told how many layers from the bottom stay visible whenever the user moves the slider
|
||||||
|
*/
|
||||||
|
ObjectLayerCut(IntConsumer onLayersChanged) {
|
||||||
|
super(new BorderLayout(0, StudioTheme.scale(4)));
|
||||||
|
this.onLayersChanged = onLayersChanged;
|
||||||
|
setOpaque(false);
|
||||||
|
setToolTipText("Hide the top layers to look inside · Page Up / Page Down");
|
||||||
|
shownLayers.setFont(StudioTheme.smallFont());
|
||||||
|
shownLayers.setForeground(StudioTheme.SECONDARY_TEXT);
|
||||||
|
slider.setOpaque(false);
|
||||||
|
slider.setFocusable(false);
|
||||||
|
slider.setToolTipText(getToolTipText());
|
||||||
|
slider.addChangeListener(event -> {
|
||||||
|
showLayerLabel();
|
||||||
|
if (!resetting) {
|
||||||
|
onLayersChanged.accept(slider.getValue());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
MouseAdapter hover = new MouseAdapter() {
|
||||||
|
@Override
|
||||||
|
public void mouseEntered(MouseEvent event) {
|
||||||
|
setHovered(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void mouseExited(MouseEvent event) {
|
||||||
|
setHovered(slider.getValueIsAdjusting());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void mouseReleased(MouseEvent event) {
|
||||||
|
setHovered(slider.contains(event.getPoint()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
slider.addMouseListener(hover);
|
||||||
|
addMouseListener(hover);
|
||||||
|
add(shownLayers, BorderLayout.NORTH);
|
||||||
|
add(slider, BorderLayout.CENTER);
|
||||||
|
showHeight(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The view with this slider floating over its right edge, halfway down.
|
||||||
|
*/
|
||||||
|
JComponent floatingOver(JComponent view) {
|
||||||
|
ObjectLayerCut cut = this;
|
||||||
|
JPanel stack = new JPanel(null) {
|
||||||
|
private static final long serialVersionUID = -1950381405658102347L;
|
||||||
|
|
||||||
|
// The slider overlaps the view, so the view may not repaint without the slider on top of it.
|
||||||
|
@Override
|
||||||
|
public boolean isOptimizedDrawingEnabled() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void doLayout() {
|
||||||
|
view.setBounds(0, 0, getWidth(), getHeight());
|
||||||
|
int width = StudioTheme.scale(PANEL_WIDTH);
|
||||||
|
int height = Math.min(StudioTheme.scale(MAX_HEIGHT), (int) (getHeight() * HEIGHT_SHARE_OF_VIEW));
|
||||||
|
cut.setBounds(getWidth() - width - StudioTheme.scale(EDGE_MARGIN), (getHeight() - height) / 2, width, height);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
stack.add(cut);
|
||||||
|
stack.add(view);
|
||||||
|
return stack;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows every layer of a newly chosen object without telling anyone, since nothing is hidden yet.
|
||||||
|
*/
|
||||||
|
void showHeight(int layers) {
|
||||||
|
resetting = true;
|
||||||
|
try {
|
||||||
|
slider.setMaximum(Math.max(1, layers));
|
||||||
|
slider.setValue(slider.getMaximum());
|
||||||
|
} finally {
|
||||||
|
resetting = false;
|
||||||
|
}
|
||||||
|
showLayerLabel();
|
||||||
|
setVisible(layers > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void stepBy(int layers) {
|
||||||
|
if (isVisible()) {
|
||||||
|
slider.setValue(slider.getValue() + layers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isCutting() {
|
||||||
|
return slider.getValue() < slider.getMaximum();
|
||||||
|
}
|
||||||
|
|
||||||
|
int shownLayerCount() {
|
||||||
|
return slider.getValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
int layerCount() {
|
||||||
|
return slider.getMaximum();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void paintChildren(Graphics graphics) {
|
||||||
|
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||||
|
try {
|
||||||
|
if (!hovered && !isCutting()) {
|
||||||
|
canvas.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, FAINT_OPACITY));
|
||||||
|
}
|
||||||
|
super.paintChildren(canvas);
|
||||||
|
} finally {
|
||||||
|
canvas.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setHovered(boolean nowHovered) {
|
||||||
|
if (hovered != nowHovered) {
|
||||||
|
hovered = nowHovered;
|
||||||
|
repaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showLayerLabel() {
|
||||||
|
shownLayers.setText(isCutting() ? Integer.toString(slider.getValue()) : " ");
|
||||||
|
repaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import java.util.ArrayList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The faces of an object's blocks that can be seen from outside. A face is left out when the neighbour on its side
|
* The faces of an object's blocks that can be seen from outside. A face is left out when the neighbour on its side
|
||||||
@@ -34,22 +35,36 @@ import java.util.function.Function;
|
|||||||
* kept apart because they are drawn last, blended over everything behind them.
|
* kept apart because they are drawn last, blended over everything behind them.
|
||||||
*/
|
*/
|
||||||
record ObjectMesh(List<Quad> solidQuads, List<Quad> translucentQuads, Bounds bounds) {
|
record ObjectMesh(List<Quad> solidQuads, List<Quad> translucentQuads, Bounds bounds) {
|
||||||
|
private static final double SEE_THROUGH_OPACITY = 0.22D;
|
||||||
|
private static final double FULL_OPACITY = 1D;
|
||||||
|
|
||||||
static ObjectMesh build(Map<BlockVector, BlockData> blocks, Function<BlockData, BlockModel> modelOf) {
|
static ObjectMesh build(Map<BlockVector, BlockData> blocks, Function<BlockData, BlockModel> modelOf) {
|
||||||
|
return build(blocks, modelOf, Bounds.of(blocks.keySet()), block -> false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param bounds the whole object's extent, kept when only part of it is drawn so the view does not jump
|
||||||
|
* @param isSeeThrough blocks drawn faintly, which hide nothing behind them, to look past them into the object
|
||||||
|
*/
|
||||||
|
static ObjectMesh build(Map<BlockVector, BlockData> blocks, Function<BlockData, BlockModel> modelOf, Bounds bounds,
|
||||||
|
Predicate<BlockData> isSeeThrough) {
|
||||||
List<Quad> solid = new ArrayList<>();
|
List<Quad> solid = new ArrayList<>();
|
||||||
List<Quad> translucent = new ArrayList<>();
|
List<Quad> translucent = new ArrayList<>();
|
||||||
for (Map.Entry<BlockVector, BlockData> entry : blocks.entrySet()) {
|
for (Map.Entry<BlockVector, BlockData> entry : blocks.entrySet()) {
|
||||||
BlockVector position = entry.getKey();
|
BlockVector position = entry.getKey();
|
||||||
BlockData block = entry.getValue();
|
BlockData block = entry.getValue();
|
||||||
BlockModel model = modelOf.apply(block);
|
BlockModel model = modelOf.apply(block);
|
||||||
|
boolean seeThrough = isSeeThrough.test(block);
|
||||||
for (Face face : model.faces()) {
|
for (Face face : model.faces()) {
|
||||||
if (isHiddenByNeighbour(blocks, modelOf, position, block, model, face)) {
|
if (isHiddenByNeighbour(blocks, modelOf, isSeeThrough, position, block, model, face)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Quad quad = new Quad(face, position.getBlockX(), position.getBlockY(), position.getBlockZ());
|
Quad quad = new Quad(face, position.getBlockX(), position.getBlockY(), position.getBlockZ(),
|
||||||
(face.texture().isTranslucent() ? translucent : solid).add(quad);
|
seeThrough ? SEE_THROUGH_OPACITY : FULL_OPACITY);
|
||||||
|
(seeThrough || face.texture().isTranslucent() ? translucent : solid).add(quad);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return new ObjectMesh(List.copyOf(solid), List.copyOf(translucent), Bounds.of(blocks.keySet()));
|
return new ObjectMesh(List.copyOf(solid), List.copyOf(translucent), bounds);
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean isEmpty() {
|
boolean isEmpty() {
|
||||||
@@ -60,9 +75,11 @@ record ObjectMesh(List<Quad> solidQuads, List<Quad> translucentQuads, Bounds bou
|
|||||||
return solidQuads.size() + translucentQuads.size();
|
return solidQuads.size() + translucentQuads.size();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Glass next to glass and water next to water hide the side between them too, as they do in the game.
|
// Glass next to glass and water next to water hide the side between them too, as they do in the game. A see-through
|
||||||
|
// block only hides sides of other see-through blocks, so whatever it covers shows through it.
|
||||||
private static boolean isHiddenByNeighbour(Map<BlockVector, BlockData> blocks, Function<BlockData, BlockModel> modelOf,
|
private static boolean isHiddenByNeighbour(Map<BlockVector, BlockData> blocks, Function<BlockData, BlockModel> modelOf,
|
||||||
BlockVector position, BlockData block, BlockModel model, Face face) {
|
Predicate<BlockData> isSeeThrough, BlockVector position, BlockData block,
|
||||||
|
BlockModel model, Face face) {
|
||||||
CubeFace side = face.cullFace();
|
CubeFace side = face.cullFace();
|
||||||
if (side == null) {
|
if (side == null) {
|
||||||
return false;
|
return false;
|
||||||
@@ -71,7 +88,7 @@ record ObjectMesh(List<Quad> solidQuads, List<Quad> translucentQuads, Bounds bou
|
|||||||
position.getBlockX() + side.stepX(),
|
position.getBlockX() + side.stepX(),
|
||||||
position.getBlockY() + side.stepY(),
|
position.getBlockY() + side.stepY(),
|
||||||
position.getBlockZ() + side.stepZ()));
|
position.getBlockZ() + side.stepZ()));
|
||||||
if (neighbour == null) {
|
if (neighbour == null || (isSeeThrough.test(neighbour) && !isSeeThrough.test(block))) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
BlockModel neighbourModel = modelOf.apply(neighbour);
|
BlockModel neighbourModel = modelOf.apply(neighbour);
|
||||||
@@ -82,9 +99,9 @@ record ObjectMesh(List<Quad> solidQuads, List<Quad> translucentQuads, Bounds bou
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A model face placed at a block in the object.
|
* A model face placed at a block in the object, drawn at the given opacity on top of its texture's own.
|
||||||
*/
|
*/
|
||||||
record Quad(Face face, int x, int y, int z) {
|
record Quad(Face face, int x, int y, int z, double opacity) {
|
||||||
double originX() {
|
double originX() {
|
||||||
return x + face.origin().x();
|
return x + face.origin().x();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ package com.volmit.iris.core.gui;
|
|||||||
|
|
||||||
import javax.swing.BorderFactory;
|
import javax.swing.BorderFactory;
|
||||||
import javax.swing.BoxLayout;
|
import javax.swing.BoxLayout;
|
||||||
|
import javax.swing.JButton;
|
||||||
|
import javax.swing.SwingUtilities;
|
||||||
import javax.swing.DefaultListCellRenderer;
|
import javax.swing.DefaultListCellRenderer;
|
||||||
import javax.swing.DefaultListModel;
|
import javax.swing.DefaultListModel;
|
||||||
import javax.swing.JLabel;
|
import javax.swing.JLabel;
|
||||||
@@ -28,18 +30,28 @@ import javax.swing.JPanel;
|
|||||||
import javax.swing.JTextField;
|
import javax.swing.JTextField;
|
||||||
import javax.swing.event.DocumentEvent;
|
import javax.swing.event.DocumentEvent;
|
||||||
import javax.swing.event.DocumentListener;
|
import javax.swing.event.DocumentListener;
|
||||||
|
import java.awt.AlphaComposite;
|
||||||
import java.awt.BorderLayout;
|
import java.awt.BorderLayout;
|
||||||
import java.awt.Component;
|
import java.awt.Component;
|
||||||
|
import java.awt.Composite;
|
||||||
|
import java.awt.Cursor;
|
||||||
|
import java.awt.Insets;
|
||||||
|
import java.awt.event.MouseAdapter;
|
||||||
|
import java.awt.event.MouseEvent;
|
||||||
import java.awt.Graphics;
|
import java.awt.Graphics;
|
||||||
import java.awt.Graphics2D;
|
import java.awt.Graphics2D;
|
||||||
import java.awt.GridLayout;
|
import java.awt.GridLayout;
|
||||||
import java.awt.RenderingHints;
|
import java.awt.RenderingHints;
|
||||||
import java.awt.image.BufferedImage;
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The chosen object's size and the blocks it is made of, most used first, like the overview in Procedural Objects.
|
* The chosen object's size and the blocks it is made of, most used first, like the overview in Procedural Objects.
|
||||||
|
* Clicking a block makes it see-through in the 3D view, to look past walls into the object.
|
||||||
*/
|
*/
|
||||||
final class ObjectOverviewPanel extends JPanel {
|
final class ObjectOverviewPanel extends JPanel {
|
||||||
private static final long serialVersionUID = -8811407613645718290L;
|
private static final long serialVersionUID = -8811407613645718290L;
|
||||||
@@ -50,6 +62,8 @@ final class ObjectOverviewPanel extends JPanel {
|
|||||||
private static final int ROW_PADDING = 10;
|
private static final int ROW_PADDING = 10;
|
||||||
private static final int SHARE_WIDTH = 92;
|
private static final int SHARE_WIDTH = 92;
|
||||||
private static final int SHARE_BAR_HEIGHT = 4;
|
private static final int SHARE_BAR_HEIGHT = 4;
|
||||||
|
private static final float SEE_THROUGH_ICON_OPACITY = 0.3F;
|
||||||
|
private static final String SEE_THROUGH_HINT = "Click a block to see through it";
|
||||||
|
|
||||||
private final JLabel title = new JLabel(" ");
|
private final JLabel title = new JLabel(" ");
|
||||||
private final JLabel path = new JLabel(" ");
|
private final JLabel path = new JLabel(" ");
|
||||||
@@ -65,10 +79,18 @@ final class ObjectOverviewPanel extends JPanel {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
private final JLabel seeThroughHint = new JLabel(SEE_THROUGH_HINT);
|
||||||
|
private final JButton showAllSolid = new JButton("Show all solid");
|
||||||
|
private final Set<String> seeThroughTypes = new LinkedHashSet<>();
|
||||||
|
private final Consumer<Set<String>> onSeeThroughChanged;
|
||||||
private ObjectSummary summary;
|
private ObjectSummary summary;
|
||||||
|
|
||||||
ObjectOverviewPanel() {
|
/**
|
||||||
|
* @param onSeeThroughChanged told which block types to draw see-through whenever the user changes them
|
||||||
|
*/
|
||||||
|
ObjectOverviewPanel(Consumer<Set<String>> onSeeThroughChanged) {
|
||||||
super(new BorderLayout());
|
super(new BorderLayout());
|
||||||
|
this.onSeeThroughChanged = onSeeThroughChanged;
|
||||||
setPreferredSize(StudioTheme.scaledSize(PANEL_WIDTH, 0));
|
setPreferredSize(StudioTheme.scaledSize(PANEL_WIDTH, 0));
|
||||||
setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, StudioTheme.SEPARATOR));
|
setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, StudioTheme.SEPARATOR));
|
||||||
setBackground(StudioTheme.PANEL_BACKGROUND);
|
setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||||
@@ -85,6 +107,8 @@ final class ObjectOverviewPanel extends JPanel {
|
|||||||
blockCount.setText(formatCount(nextSummary.blockCount()));
|
blockCount.setText(formatCount(nextSummary.blockCount()));
|
||||||
blockTypes.setText(formatCount(nextSummary.blockUses().size()));
|
blockTypes.setText(formatCount(nextSummary.blockUses().size()));
|
||||||
filled.setText(String.format(Locale.ROOT, "%.1f%%", nextSummary.filledFraction() * 100D));
|
filled.setText(String.format(Locale.ROOT, "%.1f%%", nextSummary.filledFraction() * 100D));
|
||||||
|
seeThroughTypes.clear();
|
||||||
|
showSeeThroughState();
|
||||||
refilter();
|
refilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +120,8 @@ final class ObjectOverviewPanel extends JPanel {
|
|||||||
value.setText("—");
|
value.setText("—");
|
||||||
}
|
}
|
||||||
rows.clear();
|
rows.clear();
|
||||||
|
seeThroughTypes.clear();
|
||||||
|
showSeeThroughState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private JPanel buildHeader() {
|
private JPanel buildHeader() {
|
||||||
@@ -147,11 +173,68 @@ final class ObjectOverviewPanel extends JPanel {
|
|||||||
list.setCellRenderer(new BlockUseRenderer());
|
list.setCellRenderer(new BlockUseRenderer());
|
||||||
list.setBackground(StudioTheme.PANEL_BACKGROUND);
|
list.setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||||
list.setBorder(StudioTheme.emptyBorder(0, 6, 4, 6));
|
list.setBorder(StudioTheme.emptyBorder(0, 6, 4, 6));
|
||||||
blocks.add(StudioToolbar.padded(filterField, 4, 12, 6, 12), BorderLayout.NORTH);
|
list.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||||
|
list.addMouseListener(new MouseAdapter() {
|
||||||
|
@Override
|
||||||
|
public void mouseClicked(MouseEvent event) {
|
||||||
|
int row = list.locationToIndex(event.getPoint());
|
||||||
|
if (SwingUtilities.isLeftMouseButton(event) && row >= 0 && list.getCellBounds(row, row).contains(event.getPoint())) {
|
||||||
|
toggleSeeThrough(rows.get(row).blockType());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
JPanel top = new JPanel();
|
||||||
|
top.setLayout(new BoxLayout(top, BoxLayout.Y_AXIS));
|
||||||
|
top.setOpaque(false);
|
||||||
|
top.add(leftAligned(StudioToolbar.padded(filterField, 4, 12, 4, 12)));
|
||||||
|
top.add(leftAligned(StudioToolbar.padded(seeThroughRow(), 0, 14, 6, 12)));
|
||||||
|
blocks.add(top, BorderLayout.NORTH);
|
||||||
blocks.add(StudioToolbar.scroll(list), BorderLayout.CENTER);
|
blocks.add(StudioToolbar.scroll(list), BorderLayout.CENTER);
|
||||||
return blocks;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private JPanel seeThroughRow() {
|
||||||
|
seeThroughHint.setFont(StudioTheme.smallFont());
|
||||||
|
seeThroughHint.setForeground(StudioTheme.MUTED_TEXT);
|
||||||
|
showAllSolid.setFont(StudioTheme.smallFont());
|
||||||
|
showAllSolid.setForeground(StudioTheme.LINK);
|
||||||
|
showAllSolid.setBorderPainted(false);
|
||||||
|
showAllSolid.setContentAreaFilled(false);
|
||||||
|
showAllSolid.setFocusable(false);
|
||||||
|
showAllSolid.setMargin(new Insets(0, 0, 0, 0));
|
||||||
|
showAllSolid.setBorder(BorderFactory.createEmptyBorder());
|
||||||
|
showAllSolid.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||||
|
showAllSolid.addActionListener(event -> {
|
||||||
|
seeThroughTypes.clear();
|
||||||
|
seeThroughChanged();
|
||||||
|
});
|
||||||
|
JPanel row = new JPanel(new BorderLayout());
|
||||||
|
row.setOpaque(false);
|
||||||
|
row.add(seeThroughHint, BorderLayout.CENTER);
|
||||||
|
row.add(showAllSolid, BorderLayout.EAST);
|
||||||
|
showSeeThroughState();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void toggleSeeThrough(String blockType) {
|
||||||
|
if (!seeThroughTypes.remove(blockType)) {
|
||||||
|
seeThroughTypes.add(blockType);
|
||||||
|
}
|
||||||
|
seeThroughChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void seeThroughChanged() {
|
||||||
|
showSeeThroughState();
|
||||||
|
list.repaint();
|
||||||
|
onSeeThroughChanged.accept(Set.copyOf(seeThroughTypes));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showSeeThroughState() {
|
||||||
|
int count = seeThroughTypes.size();
|
||||||
|
seeThroughHint.setText(count == 0 ? SEE_THROUGH_HINT : count == 1 ? "1 block see-through" : count + " blocks see-through");
|
||||||
|
showAllSolid.setVisible(count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
private void refilter() {
|
private void refilter() {
|
||||||
rows.clear();
|
rows.clear();
|
||||||
if (summary == null) {
|
if (summary == null) {
|
||||||
@@ -199,6 +282,7 @@ final class ObjectOverviewPanel extends JPanel {
|
|||||||
private final class BlockUseRenderer extends DefaultListCellRenderer {
|
private final class BlockUseRenderer extends DefaultListCellRenderer {
|
||||||
private static final long serialVersionUID = 2785337045812613941L;
|
private static final long serialVersionUID = 2785337045812613941L;
|
||||||
private transient BufferedImage icon;
|
private transient BufferedImage icon;
|
||||||
|
private boolean seeThrough;
|
||||||
private double share;
|
private double share;
|
||||||
private String percent = "";
|
private String percent = "";
|
||||||
|
|
||||||
@@ -206,9 +290,12 @@ final class ObjectOverviewPanel extends JPanel {
|
|||||||
public Component getListCellRendererComponent(JList<?> owner, Object value, int index, boolean selected, boolean focused) {
|
public Component getListCellRendererComponent(JList<?> owner, Object value, int index, boolean selected, boolean focused) {
|
||||||
ObjectSummary.BlockUse use = (ObjectSummary.BlockUse) value;
|
ObjectSummary.BlockUse use = (ObjectSummary.BlockUse) value;
|
||||||
super.getListCellRendererComponent(owner, use.blockType(), index, false, false);
|
super.getListCellRendererComponent(owner, use.blockType(), index, false, false);
|
||||||
|
seeThrough = seeThroughTypes.contains(use.blockType());
|
||||||
setFont(StudioTheme.font());
|
setFont(StudioTheme.font());
|
||||||
|
setForeground(seeThrough ? StudioTheme.MUTED_TEXT : StudioTheme.TEXT);
|
||||||
setBorder(StudioTheme.emptyBorder(0, ROW_PADDING + ICON_SIZE + ICON_GAP, 0, ROW_PADDING + SHARE_WIDTH));
|
setBorder(StudioTheme.emptyBorder(0, ROW_PADDING + ICON_SIZE + ICON_GAP, 0, ROW_PADDING + SHARE_WIDTH));
|
||||||
setToolTipText(use.blockType() + " · " + formatCount(use.count()) + " blocks");
|
setToolTipText(use.blockType() + " · " + formatCount(use.count()) + " blocks · click to "
|
||||||
|
+ (seeThrough ? "make solid again" : "see through it"));
|
||||||
icon = use.icon();
|
icon = use.icon();
|
||||||
share = summary == null || summary.blockCount() == 0 ? 0D : use.count() / (double) summary.blockCount();
|
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);
|
percent = share > 0D && share < 0.001D ? "<0.1%" : String.format(Locale.ROOT, "%.1f%%", share * 100D);
|
||||||
@@ -225,7 +312,12 @@ final class ObjectOverviewPanel extends JPanel {
|
|||||||
int iconY = (getHeight() - iconSize) / 2;
|
int iconY = (getHeight() - iconSize) / 2;
|
||||||
if (icon != null) {
|
if (icon != null) {
|
||||||
canvas.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
|
canvas.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
|
||||||
|
Composite solid = canvas.getComposite();
|
||||||
|
if (seeThrough) {
|
||||||
|
canvas.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, SEE_THROUGH_ICON_OPACITY));
|
||||||
|
}
|
||||||
canvas.drawImage(icon, iconX, iconY, iconSize, iconSize, null);
|
canvas.drawImage(icon, iconX, iconY, iconSize, iconSize, null);
|
||||||
|
canvas.setComposite(solid);
|
||||||
} else {
|
} else {
|
||||||
canvas.setColor(StudioTheme.CONTROL_BORDER);
|
canvas.setColor(StudioTheme.CONTROL_BORDER);
|
||||||
canvas.drawRect(iconX, iconY, iconSize - 1, iconSize - 1);
|
canvas.drawRect(iconX, iconY, iconSize - 1, iconSize - 1);
|
||||||
@@ -248,7 +340,7 @@ final class ObjectOverviewPanel extends JPanel {
|
|||||||
int barY = (getHeight() - barHeight) / 2;
|
int barY = (getHeight() - barHeight) / 2;
|
||||||
canvas.setColor(StudioTheme.HOVER_BACKGROUND);
|
canvas.setColor(StudioTheme.HOVER_BACKGROUND);
|
||||||
canvas.fillRoundRect(barX, barY, barWidth, barHeight, barHeight, barHeight);
|
canvas.fillRoundRect(barX, barY, barWidth, barHeight, barHeight, barHeight);
|
||||||
canvas.setColor(StudioTheme.ACCENT);
|
canvas.setColor(seeThrough ? StudioTheme.CONTROL_BORDER : StudioTheme.ACCENT);
|
||||||
canvas.fillRoundRect(barX, barY, Math.max(barHeight, (int) Math.round(barWidth * share)), barHeight, barHeight, barHeight);
|
canvas.fillRoundRect(barX, barY, Math.max(barHeight, (int) Math.round(barWidth * share)), barHeight, barHeight, barHeight);
|
||||||
canvas.setColor(StudioTheme.SECONDARY_TEXT);
|
canvas.setColor(StudioTheme.SECONDARY_TEXT);
|
||||||
int textWidth = canvas.getFontMetrics().stringWidth(percent);
|
int textWidth = canvas.getFontMetrics().stringWidth(percent);
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ final class ObjectRenderer {
|
|||||||
}
|
}
|
||||||
int color = BlockPreviewColors.shade(texels[texel], shade);
|
int color = BlockPreviewColors.shade(texels[texel], shade);
|
||||||
if (blended) {
|
if (blended) {
|
||||||
pixels[index] = blend(color, pixels[index], alphas == null ? 1D : alphas[texel] / FULL_ALPHA);
|
pixels[index] = blend(color, pixels[index], (alphas == null ? 1D : alphas[texel] / FULL_ALPHA) * quad.opacity());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
depths[index] = depth;
|
depths[index] = depth;
|
||||||
|
|||||||
@@ -86,6 +86,26 @@ final class ObjectViewport extends JComponent {
|
|||||||
repaint();
|
repaint();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swaps in another mesh of the same object, like one with its top layers cut away, keeping the camera.
|
||||||
|
*/
|
||||||
|
void replaceMesh(ObjectMesh nextMesh) {
|
||||||
|
mesh = nextMesh;
|
||||||
|
message = nextMesh.isEmpty() ? "No visible blocks" : null;
|
||||||
|
requestFrame();
|
||||||
|
repaint();
|
||||||
|
}
|
||||||
|
|
||||||
|
void bindKey(int keyCode, String name, Runnable action) {
|
||||||
|
getInputMap(WHEN_FOCUSED).put(KeyStroke.getKeyStroke(keyCode, 0), name);
|
||||||
|
getActionMap().put(name, new AbstractAction() {
|
||||||
|
@Override
|
||||||
|
public void actionPerformed(ActionEvent event) {
|
||||||
|
action.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void showMessage(String text) {
|
void showMessage(String text) {
|
||||||
mesh = null;
|
mesh = null;
|
||||||
message = text;
|
message = text;
|
||||||
@@ -218,12 +238,6 @@ final class ObjectViewport extends JComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void bindKey(int keyCode, String name, UnaryOperator<ObjectCamera> move) {
|
private void bindKey(int keyCode, String name, UnaryOperator<ObjectCamera> move) {
|
||||||
getInputMap(WHEN_FOCUSED).put(KeyStroke.getKeyStroke(keyCode, 0), name);
|
bindKey(keyCode, name, () -> moveCamera(move));
|
||||||
getActionMap().put(name, new AbstractAction() {
|
|
||||||
@Override
|
|
||||||
public void actionPerformed(ActionEvent event) {
|
|
||||||
moveCamera(move);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
/*
|
||||||
|
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||||
|
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
package com.volmit.iris.core.gui;
|
||||||
|
|
||||||
|
import javax.swing.CellRendererPane;
|
||||||
|
import javax.swing.Icon;
|
||||||
|
import javax.swing.JComponent;
|
||||||
|
import javax.swing.JLayer;
|
||||||
|
import javax.swing.JScrollPane;
|
||||||
|
import javax.swing.JTree;
|
||||||
|
import javax.swing.JViewport;
|
||||||
|
import javax.swing.SwingUtilities;
|
||||||
|
import javax.swing.UIManager;
|
||||||
|
import javax.swing.plaf.LayerUI;
|
||||||
|
import javax.swing.plaf.basic.BasicTreeUI;
|
||||||
|
import javax.swing.tree.TreePath;
|
||||||
|
import java.awt.AWTEvent;
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.awt.Component;
|
||||||
|
import java.awt.Cursor;
|
||||||
|
import java.awt.GradientPaint;
|
||||||
|
import java.awt.Graphics;
|
||||||
|
import java.awt.Graphics2D;
|
||||||
|
import java.awt.Point;
|
||||||
|
import java.awt.Rectangle;
|
||||||
|
import java.awt.event.MouseEvent;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the folders around the top row of a scrolled tree pinned at the top, so a long folder never hides where it
|
||||||
|
* is. The next folder pushes the last header up as it arrives, and clicking a header jumps to its folder.
|
||||||
|
*/
|
||||||
|
final class StickyFolderHeaders extends LayerUI<JScrollPane> {
|
||||||
|
private static final long serialVersionUID = -2205474187531254372L;
|
||||||
|
private static final int SHADOW_HEIGHT = 4;
|
||||||
|
private static final Color SHADOW = new Color(0, 0, 0, 90);
|
||||||
|
private static final Color NO_SHADOW = new Color(0, 0, 0, 0);
|
||||||
|
// The folder handle sits one indent to the left of the folder's name, as the tree draws it.
|
||||||
|
private static final int HANDLE_NUDGE = 1;
|
||||||
|
|
||||||
|
private final JTree tree;
|
||||||
|
private final CellRendererPane rendererPane = new CellRendererPane();
|
||||||
|
private transient JLayer<JScrollPane> layer;
|
||||||
|
private int hoveredHeader = -1;
|
||||||
|
|
||||||
|
private StickyFolderHeaders(JTree tree) {
|
||||||
|
this.tree = tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
static JLayer<JScrollPane> around(JTree tree, JScrollPane scrollPane) {
|
||||||
|
// Copying the scrolled pixels would drag the pinned headers along, so every scroll repaints instead.
|
||||||
|
scrollPane.getViewport().setScrollMode(JViewport.SIMPLE_SCROLL_MODE);
|
||||||
|
StickyFolderHeaders headers = new StickyFolderHeaders(tree);
|
||||||
|
JLayer<JScrollPane> layer = new JLayer<>(scrollPane, headers);
|
||||||
|
scrollPane.getViewport().addChangeListener(event -> layer.repaint());
|
||||||
|
return layer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public void installUI(JComponent component) {
|
||||||
|
super.installUI(component);
|
||||||
|
layer = (JLayer<JScrollPane>) component;
|
||||||
|
layer.setLayerEventMask(AWTEvent.MOUSE_EVENT_MASK | AWTEvent.MOUSE_MOTION_EVENT_MASK);
|
||||||
|
tree.add(rendererPane);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void uninstallUI(JComponent component) {
|
||||||
|
layer.setLayerEventMask(0);
|
||||||
|
tree.remove(rendererPane);
|
||||||
|
layer = null;
|
||||||
|
super.uninstallUI(component);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void paint(Graphics graphics, JComponent component) {
|
||||||
|
super.paint(graphics, component);
|
||||||
|
List<Header> headers = pinnedHeaders();
|
||||||
|
if (headers.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||||
|
try {
|
||||||
|
Rectangle viewport = viewportBounds();
|
||||||
|
canvas.clipRect(viewport.x, viewport.y, viewport.width, viewport.height);
|
||||||
|
for (int index = 0; index < headers.size(); index++) {
|
||||||
|
paintHeader(canvas, headers.get(index), index == hoveredHeader, viewport);
|
||||||
|
}
|
||||||
|
Header last = headers.get(headers.size() - 1);
|
||||||
|
int shadowTop = viewport.y + last.top() + tree.getRowHeight();
|
||||||
|
canvas.setPaint(new GradientPaint(0, shadowTop, SHADOW, 0, shadowTop + StudioTheme.scale(SHADOW_HEIGHT), NO_SHADOW));
|
||||||
|
canvas.fillRect(viewport.x, shadowTop, viewport.width, StudioTheme.scale(SHADOW_HEIGHT));
|
||||||
|
} finally {
|
||||||
|
canvas.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void paintHeader(Graphics2D canvas, Header header, boolean hovered, Rectangle viewport) {
|
||||||
|
int rowHeight = tree.getRowHeight();
|
||||||
|
int top = viewport.y + header.top();
|
||||||
|
Rectangle row = tree.getRowBounds(header.row());
|
||||||
|
int left = viewport.x + row.x - scrolledView().x;
|
||||||
|
canvas.setColor(hovered ? StudioTheme.HOVER_BACKGROUND : tree.getBackground());
|
||||||
|
canvas.fillRect(viewport.x, top, viewport.width, rowHeight);
|
||||||
|
paintFolderHandle(canvas, left, top + rowHeight / 2);
|
||||||
|
Component renderer = tree.getCellRenderer().getTreeCellRendererComponent(
|
||||||
|
tree, header.path().getLastPathComponent(), false, true, false, header.row(), false);
|
||||||
|
rendererPane.paintComponent(canvas, renderer, tree, left, top, viewport.x + viewport.width - left, rowHeight, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void paintFolderHandle(Graphics2D canvas, int folderLeft, int middleY) {
|
||||||
|
Icon handle = UIManager.getIcon("Tree.expandedIcon");
|
||||||
|
if (handle == null || !(tree.getUI() instanceof BasicTreeUI treeUI)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int middleX = folderLeft - treeUI.getRightChildIndent() + HANDLE_NUDGE;
|
||||||
|
handle.paintIcon(tree, canvas, middleX - handle.getIconWidth() / 2, middleY - handle.getIconHeight() / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void processMouseEvent(MouseEvent event, JLayer<? extends JScrollPane> source) {
|
||||||
|
if (event.getID() == MouseEvent.MOUSE_EXITED) {
|
||||||
|
hover(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int index = headerAt(event);
|
||||||
|
if (index < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.consume();
|
||||||
|
if (event.getID() == MouseEvent.MOUSE_CLICKED && SwingUtilities.isLeftMouseButton(event)) {
|
||||||
|
jumpTo(pinnedHeaders().get(index), index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void processMouseMotionEvent(MouseEvent event, JLayer<? extends JScrollPane> source) {
|
||||||
|
hover(headerAt(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void hover(int index) {
|
||||||
|
if (index == hoveredHeader) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hoveredHeader = index;
|
||||||
|
tree.setCursor(index >= 0 ? Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) : null);
|
||||||
|
layer.repaint();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The folder lands just below the headers of the folders around it.
|
||||||
|
private void jumpTo(Header header, int level) {
|
||||||
|
Rectangle row = tree.getRowBounds(header.row());
|
||||||
|
JViewport viewport = layer.getView().getViewport();
|
||||||
|
int top = Math.max(0, row.y - level * tree.getRowHeight());
|
||||||
|
int bottomLimit = Math.max(0, tree.getHeight() - viewport.getExtentSize().height);
|
||||||
|
viewport.setViewPosition(new Point(viewport.getViewPosition().x, Math.min(top, bottomLimit)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private int headerAt(MouseEvent event) {
|
||||||
|
if (layer == null || event.getComponent() == null) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
Point point = SwingUtilities.convertPoint(event.getComponent(), event.getPoint(), layer);
|
||||||
|
Rectangle viewport = viewportBounds();
|
||||||
|
if (!viewport.contains(point)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
List<Header> headers = pinnedHeaders();
|
||||||
|
for (int index = headers.size() - 1; index >= 0; index--) {
|
||||||
|
int top = viewport.y + headers.get(index).top();
|
||||||
|
if (point.y >= top && point.y < top + tree.getRowHeight()) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For each depth, the folder holding the row just under the headers above it, as long as that folder's own row
|
||||||
|
* has scrolled out of sight.
|
||||||
|
*/
|
||||||
|
private List<Header> pinnedHeaders() {
|
||||||
|
List<Header> headers = new ArrayList<>();
|
||||||
|
int rowHeight = tree.getRowHeight();
|
||||||
|
if (layer == null || rowHeight <= 0 || tree.getRowCount() == 0) {
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
Rectangle view = scrolledView();
|
||||||
|
for (int level = 0; ; level++) {
|
||||||
|
int slotTop = level * rowHeight;
|
||||||
|
int row = tree.getClosestRowForLocation(view.x, view.y + slotTop);
|
||||||
|
TreePath path = row < 0 ? null : tree.getPathForRow(row);
|
||||||
|
if (path == null || !isInsideFolderAt(path, level)) {
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
TreePath folder = ancestorAt(path, level);
|
||||||
|
Rectangle lastRow = tree.getRowBounds(lastRowInside(folder, row));
|
||||||
|
int top = Math.min(slotTop, lastRow.y + lastRow.height - view.y - rowHeight);
|
||||||
|
headers.add(new Header(folder, tree.getRowForPath(folder), top));
|
||||||
|
if (top < slotTop) {
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The hidden root is the path's first element, so the folder at depth 0 is its second.
|
||||||
|
private static boolean isInsideFolderAt(TreePath path, int level) {
|
||||||
|
return path.getPathCount() > level + 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TreePath ancestorAt(TreePath path, int level) {
|
||||||
|
TreePath ancestor = path;
|
||||||
|
while (ancestor.getPathCount() > level + 2) {
|
||||||
|
ancestor = ancestor.getParentPath();
|
||||||
|
}
|
||||||
|
return ancestor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A folder's rows sit together, so the last one is found by halving the rows after a row known to be inside.
|
||||||
|
private int lastRowInside(TreePath folder, int rowInside) {
|
||||||
|
int low = rowInside;
|
||||||
|
int high = tree.getRowCount() - 1;
|
||||||
|
while (low < high) {
|
||||||
|
int middle = (low + high + 1) >>> 1;
|
||||||
|
if (folder.isDescendant(tree.getPathForRow(middle))) {
|
||||||
|
low = middle;
|
||||||
|
} else {
|
||||||
|
high = middle - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return low;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Rectangle scrolledView() {
|
||||||
|
return layer.getView().getViewport().getViewRect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Rectangle viewportBounds() {
|
||||||
|
JViewport viewport = layer.getView().getViewport();
|
||||||
|
Point origin = SwingUtilities.convertPoint(viewport, 0, 0, layer);
|
||||||
|
return new Rectangle(origin.x, origin.y, viewport.getWidth(), viewport.getHeight());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pinned folder, its row in the tree and how far below the top of the visible rows it is drawn.
|
||||||
|
*/
|
||||||
|
private record Header(TreePath path, int row, int top) {
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user