From 99258bfd9883e560f93f81e68e6052c038e3c840 Mon Sep 17 00:00:00 2001 From: piexel Date: Mon, 19 Oct 2026 21:39:42 +0200 Subject: [PATCH] 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 --- .../com/volmit/iris/core/gui/MapWindow.java | 2 +- .../volmit/iris/core/gui/ObjectBrowser.java | 2 +- .../volmit/iris/core/gui/ObjectExplorer.java | 87 +++++- .../volmit/iris/core/gui/ObjectLayerCut.java | 179 ++++++++++++ .../com/volmit/iris/core/gui/ObjectMesh.java | 35 ++- .../iris/core/gui/ObjectOverviewPanel.java | 100 ++++++- .../volmit/iris/core/gui/ObjectRenderer.java | 2 +- .../volmit/iris/core/gui/ObjectViewport.java | 28 +- .../iris/core/gui/StickyFolderHeaders.java | 268 ++++++++++++++++++ 9 files changed, 674 insertions(+), 29 deletions(-) create mode 100644 core/src/main/java/com/volmit/iris/core/gui/ObjectLayerCut.java create mode 100644 core/src/main/java/com/volmit/iris/core/gui/StickyFolderHeaders.java 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 047e7aadb..d7df45f07 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 @@ -270,7 +270,7 @@ public final class MapWindow implements MapCanvas.Listener { viewport.setCoveredLeftWidth(sidebarIsland.getWidth()); } }); - content.add(viewport, OBJECTS_CARD); + content.add(objectExplorer.view(), OBJECTS_CARD); statusBars.add(objectExplorer.statusBar(), OBJECTS_CARD); } 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 index 77c897cc4..8b487e0d9 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/ObjectBrowser.java +++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectBrowser.java @@ -91,7 +91,7 @@ final class ObjectBrowser extends JPanel { 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(StickyFolderHeaders.around(tree, StudioToolbar.scroll(tree)), BorderLayout.CENTER); add(StudioToolbar.padded(countLabel, 6, 12, 8, 12), BorderLayout.SOUTH); } 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 index 1644b8d00..84601fdfc 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/ObjectExplorer.java +++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectExplorer.java @@ -27,12 +27,14 @@ import org.bukkit.util.BlockVector; import javax.swing.JComponent; import java.awt.EventQueue; +import java.awt.event.KeyEvent; import java.awt.image.BufferedImage; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; @@ -46,13 +48,15 @@ import java.util.concurrent.atomic.AtomicLong; 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 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 UUID worldId; private final ObjectBrowser browser = new ObjectBrowser(this::showObject); 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 ExecutorService loader = Executors.newSingleThreadExecutor(runnable -> { Thread thread = new Thread(runnable, "Iris Object Loader"); @@ -61,11 +65,14 @@ final class ObjectExplorer { return thread; }); private final AtomicLong objectRevision = new AtomicLong(); + private final AtomicLong meshRevision = 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; + private LoadedObject shown; + private Set seeThroughTypes = Set.of(); ObjectExplorer(Engine engine, UUID worldId) { this.startEngine = engine; @@ -73,6 +80,8 @@ final class ObjectExplorer { GuiHost.interruptOnPluginShutdown(loader); GuiHost.registerHotloadHook(hotloadHook); 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() { @@ -83,6 +92,13 @@ final class ObjectExplorer { return viewport; } + /** + * The 3D view with the slider that cuts away its top layers. + */ + JComponent view() { + return viewWithLayerCut; + } + JComponent overview() { return overview; } @@ -133,6 +149,7 @@ final class ObjectExplorer { if (key == null) { viewport.showMessage("This pack has no objects"); overview.clear(); + layerCut.showHeight(1); return; } browser.select(key); @@ -141,6 +158,8 @@ final class ObjectExplorer { private void showObject(String key) { shownKey = key; + shown = null; + meshRevision.incrementAndGet(); long revision = objectRevision.incrementAndGet(); viewport.showMessage("Loading " + key + "..."); statusBar.showActivity("Loading " + key + "..."); @@ -170,18 +189,73 @@ final class ObjectExplorer { }); ObjectMesh mesh = ObjectMesh.build(blocks, this::modelOf); 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) { if (revision != objectRevision.get()) { return; } + shown = loaded; + seeThroughTypes = Set.of(); 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().faceCount())); + layerCut.showHeight(loaded.mesh().bounds().sizeY()); + 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 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 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 blocksUpTo(Map blocks, int highestY) { + Map 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) { @@ -191,6 +265,7 @@ final class ObjectExplorer { statusBar.showActivity(""); viewport.showMessage("Could not load " + key); overview.clear(); + layerCut.showHeight(1); 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)); } - private record LoadedObject(ObjectMesh mesh, ObjectSummary summary) { + private record LoadedObject(Map blocks, ObjectMesh mesh, ObjectSummary summary) { } } diff --git a/core/src/main/java/com/volmit/iris/core/gui/ObjectLayerCut.java b/core/src/main/java/com/volmit/iris/core/gui/ObjectLayerCut.java new file mode 100644 index 000000000..2fa4de52a --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectLayerCut.java @@ -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 . + */ + + +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(); + } +} 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 index b32037f79..50f2a970c 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/ObjectMesh.java +++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectMesh.java @@ -27,6 +27,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; 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 @@ -34,22 +35,36 @@ import java.util.function.Function; * kept apart because they are drawn last, blended over everything behind them. */ record ObjectMesh(List solidQuads, List translucentQuads, Bounds bounds) { + private static final double SEE_THROUGH_OPACITY = 0.22D; + private static final double FULL_OPACITY = 1D; + static ObjectMesh build(Map blocks, Function 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 blocks, Function modelOf, Bounds bounds, + Predicate isSeeThrough) { List solid = new ArrayList<>(); List translucent = new ArrayList<>(); for (Map.Entry entry : blocks.entrySet()) { BlockVector position = entry.getKey(); BlockData block = entry.getValue(); BlockModel model = modelOf.apply(block); + boolean seeThrough = isSeeThrough.test(block); for (Face face : model.faces()) { - if (isHiddenByNeighbour(blocks, modelOf, position, block, model, face)) { + if (isHiddenByNeighbour(blocks, modelOf, isSeeThrough, position, block, model, face)) { continue; } - Quad quad = new Quad(face, position.getBlockX(), position.getBlockY(), position.getBlockZ()); - (face.texture().isTranslucent() ? translucent : solid).add(quad); + Quad quad = new Quad(face, position.getBlockX(), position.getBlockY(), position.getBlockZ(), + 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() { @@ -60,9 +75,11 @@ record ObjectMesh(List solidQuads, List translucentQuads, Bounds bou 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 blocks, Function modelOf, - BlockVector position, BlockData block, BlockModel model, Face face) { + Predicate isSeeThrough, BlockVector position, BlockData block, + BlockModel model, Face face) { CubeFace side = face.cullFace(); if (side == null) { return false; @@ -71,7 +88,7 @@ record ObjectMesh(List solidQuads, List translucentQuads, Bounds bou position.getBlockX() + side.stepX(), position.getBlockY() + side.stepY(), position.getBlockZ() + side.stepZ())); - if (neighbour == null) { + if (neighbour == null || (isSeeThrough.test(neighbour) && !isSeeThrough.test(block))) { return false; } BlockModel neighbourModel = modelOf.apply(neighbour); @@ -82,9 +99,9 @@ record ObjectMesh(List solidQuads, List 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() { return x + face.origin().x(); } 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 index 107cbe10f..ce820e09a 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/ObjectOverviewPanel.java +++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectOverviewPanel.java @@ -20,6 +20,8 @@ package com.volmit.iris.core.gui; import javax.swing.BorderFactory; import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.SwingUtilities; import javax.swing.DefaultListCellRenderer; import javax.swing.DefaultListModel; import javax.swing.JLabel; @@ -28,18 +30,28 @@ import javax.swing.JPanel; import javax.swing.JTextField; import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; +import java.awt.AlphaComposite; import java.awt.BorderLayout; 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.Graphics2D; import java.awt.GridLayout; import java.awt.RenderingHints; import java.awt.image.BufferedImage; +import java.util.LinkedHashSet; import java.util.List; 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. + * Clicking a block makes it see-through in the 3D view, to look past walls into the object. */ final class ObjectOverviewPanel extends JPanel { 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 SHARE_WIDTH = 92; 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 path = new JLabel(" "); @@ -65,10 +79,18 @@ final class ObjectOverviewPanel extends JPanel { return true; } }; + private final JLabel seeThroughHint = new JLabel(SEE_THROUGH_HINT); + private final JButton showAllSolid = new JButton("Show all solid"); + private final Set seeThroughTypes = new LinkedHashSet<>(); + private final Consumer> onSeeThroughChanged; private ObjectSummary summary; - ObjectOverviewPanel() { + /** + * @param onSeeThroughChanged told which block types to draw see-through whenever the user changes them + */ + ObjectOverviewPanel(Consumer> onSeeThroughChanged) { super(new BorderLayout()); + this.onSeeThroughChanged = onSeeThroughChanged; setPreferredSize(StudioTheme.scaledSize(PANEL_WIDTH, 0)); setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, StudioTheme.SEPARATOR)); setBackground(StudioTheme.PANEL_BACKGROUND); @@ -85,6 +107,8 @@ final class ObjectOverviewPanel extends JPanel { blockCount.setText(formatCount(nextSummary.blockCount())); blockTypes.setText(formatCount(nextSummary.blockUses().size())); filled.setText(String.format(Locale.ROOT, "%.1f%%", nextSummary.filledFraction() * 100D)); + seeThroughTypes.clear(); + showSeeThroughState(); refilter(); } @@ -96,6 +120,8 @@ final class ObjectOverviewPanel extends JPanel { value.setText("—"); } rows.clear(); + seeThroughTypes.clear(); + showSeeThroughState(); } private JPanel buildHeader() { @@ -147,11 +173,68 @@ final class ObjectOverviewPanel extends JPanel { 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); + 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); 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() { rows.clear(); if (summary == null) { @@ -199,6 +282,7 @@ final class ObjectOverviewPanel extends JPanel { private final class BlockUseRenderer extends DefaultListCellRenderer { private static final long serialVersionUID = 2785337045812613941L; private transient BufferedImage icon; + private boolean seeThrough; private double share; 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) { ObjectSummary.BlockUse use = (ObjectSummary.BlockUse) value; super.getListCellRendererComponent(owner, use.blockType(), index, false, false); + seeThrough = seeThroughTypes.contains(use.blockType()); 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)); - 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(); 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); @@ -225,7 +312,12 @@ final class ObjectOverviewPanel extends JPanel { int iconY = (getHeight() - iconSize) / 2; if (icon != null) { 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.setComposite(solid); } else { canvas.setColor(StudioTheme.CONTROL_BORDER); canvas.drawRect(iconX, iconY, iconSize - 1, iconSize - 1); @@ -248,7 +340,7 @@ final class ObjectOverviewPanel extends JPanel { int barY = (getHeight() - barHeight) / 2; canvas.setColor(StudioTheme.HOVER_BACKGROUND); 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.setColor(StudioTheme.SECONDARY_TEXT); int textWidth = canvas.getFontMetrics().stringWidth(percent); 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 index 60c37117d..662a3f0bf 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/ObjectRenderer.java +++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectRenderer.java @@ -193,7 +193,7 @@ final class ObjectRenderer { } int color = BlockPreviewColors.shade(texels[texel], shade); 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; } depths[index] = depth; 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 index ba99a6b80..56d71b637 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/ObjectViewport.java +++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectViewport.java @@ -86,6 +86,26 @@ final class ObjectViewport extends JComponent { 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) { mesh = null; message = text; @@ -218,12 +238,6 @@ final class ObjectViewport extends JComponent { } 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); - } - }); + bindKey(keyCode, name, () -> moveCamera(move)); } } diff --git a/core/src/main/java/com/volmit/iris/core/gui/StickyFolderHeaders.java b/core/src/main/java/com/volmit/iris/core/gui/StickyFolderHeaders.java new file mode 100644 index 000000000..fdf2a82bd --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/StickyFolderHeaders.java @@ -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 . + */ + + +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 { + 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 layer; + private int hoveredHeader = -1; + + private StickyFolderHeaders(JTree tree) { + this.tree = tree; + } + + static JLayer 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 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) 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
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 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 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
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
pinnedHeaders() { + List
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) { + } +}