From e58d065ca4d206fb9881b5b04009376a8aa1dd67 Mon Sep 17 00:00:00 2001 From: piexel Date: Mon, 19 Oct 2026 12:46:35 +0200 Subject: [PATCH] Make the map explain what it shows Highlight a biome or region (legend click, right-click, or H under the cursor) to fade everything else and outline it; Esc clears it. A live legend beside the map lists the biomes or regions in view with their share of the screen. The hover card now answers at once from the drawn tiles and then adds height, sea level, land or sea, slope, cave biome and the Minecraft biome; Shift adds keys and files. After a refresh or hotload, C compares with the map from before: a draggable before/now divider, or a view that fades out everything that stayed the same and says how much changed. Shading (S) adds hillshade relief to every view and Borders (B) outlines the regions. Tiles now remember what each pixel shows, so all of this restyles without rendering again. Model used: Claude Opus 5.5 --- .../iris/core/gui/HotloadComparison.java | 279 +++++++ .../com/volmit/iris/core/gui/MapCanvas.java | 315 ++++++-- .../com/volmit/iris/core/gui/MapCensus.java | 109 +++ .../volmit/iris/core/gui/MapHoverCard.java | 112 +++ .../volmit/iris/core/gui/MapHoverProbe.java | 90 +++ .../volmit/iris/core/gui/MapLegendPanel.java | 204 +++++ .../com/volmit/iris/core/gui/MapWindow.java | 44 ++ .../iris/core/gui/VisionRenderController.java | 110 ++- .../core/gui/components/IrisRenderer.java | 744 ++++++++---------- .../iris/core/gui/components/MapLabels.java | 80 ++ .../iris/core/gui/components/MapStyle.java | 43 + .../iris/core/gui/components/MapTile.java | 210 +++++ 12 files changed, 1823 insertions(+), 517 deletions(-) create mode 100644 core/src/main/java/com/volmit/iris/core/gui/HotloadComparison.java create mode 100644 core/src/main/java/com/volmit/iris/core/gui/MapCensus.java create mode 100644 core/src/main/java/com/volmit/iris/core/gui/MapHoverCard.java create mode 100644 core/src/main/java/com/volmit/iris/core/gui/MapHoverProbe.java create mode 100644 core/src/main/java/com/volmit/iris/core/gui/MapLegendPanel.java create mode 100644 core/src/main/java/com/volmit/iris/core/gui/components/MapLabels.java create mode 100644 core/src/main/java/com/volmit/iris/core/gui/components/MapStyle.java create mode 100644 core/src/main/java/com/volmit/iris/core/gui/components/MapTile.java diff --git a/core/src/main/java/com/volmit/iris/core/gui/HotloadComparison.java b/core/src/main/java/com/volmit/iris/core/gui/HotloadComparison.java new file mode 100644 index 000000000..9df922a78 --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/HotloadComparison.java @@ -0,0 +1,279 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.volmit.iris.core.gui; + +import com.volmit.iris.core.gui.components.MapStyle; +import com.volmit.iris.core.gui.components.RenderType; + +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.awt.image.DataBufferInt; +import java.util.Locale; + +/** + * Keeps the picture of the map from just before a hotload, and shows it next to the new one: either split by a + * draggable divider, or with everything that stayed the same faded out. + */ +final class HotloadComparison { + private static final double DEFAULT_SWIPE_FRACTION = 0.5D; + private static final int DIVIDER_GRAB_DISTANCE = 8; + private static final int DIVIDER_HANDLE_RADIUS = 9; + private static final int UNCHANGED_FADE_TARGET = 0x1E1F22; + private static final double UNCHANGED_FADE = 0.78D; + private static final Color DIVIDER = new Color(255, 255, 255, 220); + + private BufferedImage before; + private Scene beforeScene; + private Mode mode = Mode.OFF; + private double swipeFraction = DEFAULT_SWIPE_FRACTION; + private BufferedImage changes; + private ChangesKey changesKey; + private double changedShare; + + enum Mode { + OFF, + SWIPE, + CHANGES; + + Mode next() { + Mode[] modes = values(); + return modes[(ordinal() + 1) % modes.length]; + } + } + + /** + * What a map picture shows, so the before picture is only laid over a now picture of the same kind and zoom. + */ + record Scene(RenderType type, MapStyle style, double blocksPerPixel, double centerX, double centerZ) { + boolean looksLike(Scene other) { + return type == other.type && style.equals(other.style); + } + } + + boolean hasBefore() { + return before != null; + } + + Mode mode() { + return mode; + } + + void setMode(Mode nextMode) { + mode = nextMode; + } + + void capture(BufferedImage layer, Scene scene) { + BufferedImage copy = new BufferedImage(layer.getWidth(), layer.getHeight(), BufferedImage.TYPE_INT_RGB); + copy.setAccelerationPriority(0F); + Graphics2D graphics = copy.createGraphics(); + try { + graphics.drawImage(layer, 0, 0, null); + } finally { + graphics.dispose(); + } + before = copy; + beforeScene = scene; + changes = null; + changesKey = null; + } + + void forget() { + before = null; + beforeScene = null; + changes = null; + changesKey = null; + mode = Mode.OFF; + } + + boolean isShowing() { + return mode != Mode.OFF && before != null; + } + + boolean isNearDivider(int screenX, int width) { + return isShowing() && mode == Mode.SWIPE + && Math.abs(screenX - dividerX(width)) <= StudioTheme.scale(DIVIDER_GRAB_DISTANCE); + } + + void moveDivider(int screenX, int width) { + swipeFraction = Math.max(0D, Math.min(1D, screenX / (double) Math.max(1, width))); + } + + /** + * Draws the comparison in place of the plain map picture. + * + * @param nowVersion changes whenever the now picture is redrawn, so the change mask is only rebuilt when needed + * @param nowComplete whether every tile of the now picture has been drawn; changes are only counted then + */ + void paint(Graphics2D canvas, BufferedImage now, long nowVersion, Scene nowScene, boolean nowComplete) { + canvas.drawImage(now, 0, 0, null); + if (!beforeScene.looksLike(nowScene)) { + drawCaption(canvas, now.getWidth(), "Before and after needs the view and layers the hotload had"); + return; + } + if (mode == Mode.SWIPE) { + paintSwipe(canvas, now.getWidth(), now.getHeight(), nowScene); + return; + } + if (beforeScene.blocksPerPixel() != nowScene.blocksPerPixel()) { + drawCaption(canvas, now.getWidth(), "Changes needs the zoom the hotload had"); + return; + } + if (!nowComplete) { + drawCaption(canvas, now.getWidth(), "Comparing once the map has finished drawing"); + return; + } + int offsetX = (int) Math.round(beforeScreenX(now.getWidth(), nowScene)); + int offsetZ = (int) Math.round(beforeScreenZ(now.getHeight(), nowScene)); + canvas.drawImage(changesImage(now, nowVersion, offsetX, offsetZ), 0, 0, null); + drawCaption(canvas, now.getWidth(), changesCaption()); + } + + private void paintSwipe(Graphics2D canvas, int width, int height, Scene now) { + int dividerX = dividerX(width); + double scale = beforeScene.blocksPerPixel() / now.blocksPerPixel(); + Graphics2D beforeSide = (Graphics2D) canvas.create(); + try { + beforeSide.clipRect(0, 0, dividerX, height); + beforeSide.drawImage(before, + (int) Math.round(beforeScreenX(width, now)), + (int) Math.round(beforeScreenZ(height, now)), + (int) Math.round(before.getWidth() * scale), + (int) Math.round(before.getHeight() * scale), + null); + } finally { + beforeSide.dispose(); + } + canvas.setColor(DIVIDER); + canvas.setStroke(new BasicStroke(StudioTheme.scale(2))); + canvas.drawLine(dividerX, 0, dividerX, height); + int radius = StudioTheme.scale(DIVIDER_HANDLE_RADIUS); + canvas.fillOval(dividerX - radius, height / 2 - radius, radius * 2, radius * 2); + canvas.setColor(StudioTheme.EDITOR_BACKGROUND); + int gripHalfHeight = radius / 2; + int gripGap = Math.max(1, radius / 4); + canvas.drawLine(dividerX - gripGap, height / 2 - gripHalfHeight, dividerX - gripGap, height / 2 + gripHalfHeight); + canvas.drawLine(dividerX + gripGap, height / 2 - gripHalfHeight, dividerX + gripGap, height / 2 + gripHalfHeight); + drawSideLabel(canvas, "Before", dividerX - StudioTheme.scale(12), 1D); + drawSideLabel(canvas, "Now", dividerX + StudioTheme.scale(12), 0D); + } + + private int dividerX(int width) { + return (int) Math.round(width * swipeFraction); + } + + // Where the before picture's top left corner lands on screen now, after any panning, zooming or resizing since. + private double beforeScreenX(int width, Scene now) { + double beforeLeftWorld = beforeScene.centerX() - before.getWidth() * 0.5D * beforeScene.blocksPerPixel(); + return width * 0.5D + (beforeLeftWorld - now.centerX()) / now.blocksPerPixel(); + } + + private double beforeScreenZ(int height, Scene now) { + double beforeTopWorld = beforeScene.centerZ() - before.getHeight() * 0.5D * beforeScene.blocksPerPixel(); + return height * 0.5D + (beforeTopWorld - now.centerZ()) / now.blocksPerPixel(); + } + + private BufferedImage changesImage(BufferedImage now, long nowVersion, int offsetX, int offsetZ) { + ChangesKey key = new ChangesKey(nowVersion, offsetX, offsetZ, now.getWidth(), now.getHeight()); + if (changes != null && key.equals(changesKey)) { + return changes; + } + int width = now.getWidth(); + int height = now.getHeight(); + int[] nowPixels = pixels(now); + int[] beforePixels = pixels(before); + BufferedImage mask = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + mask.setAccelerationPriority(0F); + int[] maskPixels = pixels(mask); + long compared = 0L; + long changed = 0L; + for (int y = 0; y < height; y++) { + int beforeY = y - offsetZ; + boolean rowInBefore = beforeY >= 0 && beforeY < before.getHeight(); + for (int x = 0; x < width; x++) { + int index = y * width + x; + int nowColor = nowPixels[index] & 0xFFFFFF; + int beforeX = x - offsetX; + boolean inBefore = rowInBefore && beforeX >= 0 && beforeX < before.getWidth(); + boolean differs = inBefore && (beforePixels[beforeY * before.getWidth() + beforeX] & 0xFFFFFF) != nowColor; + if (inBefore) { + compared++; + } + if (differs) { + changed++; + maskPixels[index] = nowColor; + } else { + maskPixels[index] = fade(nowColor); + } + } + } + changedShare = compared == 0L ? 0D : changed / (double) compared; + changes = mask; + changesKey = key; + return mask; + } + + private String changesCaption() { + if (changedShare == 0D) { + return "Nothing on screen changed in the hotload"; + } + double percent = changedShare * 100D; + String amount = percent < 0.1D ? "<0.1" : String.format(Locale.ROOT, percent < 10D ? "%.1f" : "%.0f", percent); + return amount + "% of the map on screen changed in the hotload"; + } + + private static int[] pixels(BufferedImage image) { + return ((DataBufferInt) image.getRaster().getDataBuffer()).getData(); + } + + private static int fade(int color) { + int red = (int) (((color >> 16) & 0xFF) * (1D - UNCHANGED_FADE) + ((UNCHANGED_FADE_TARGET >> 16) & 0xFF) * UNCHANGED_FADE); + int green = (int) (((color >> 8) & 0xFF) * (1D - UNCHANGED_FADE) + ((UNCHANGED_FADE_TARGET >> 8) & 0xFF) * UNCHANGED_FADE); + int blue = (int) ((color & 0xFF) * (1D - UNCHANGED_FADE) + (UNCHANGED_FADE_TARGET & 0xFF) * UNCHANGED_FADE); + return (red << 16) | (green << 8) | blue; + } + + private static void drawSideLabel(Graphics2D canvas, String text, int anchorX, double pushX) { + canvas.setFont(StudioTheme.boldFont()); + int padding = StudioTheme.scale(8); + int width = canvas.getFontMetrics().stringWidth(text) + padding * 2; + int height = canvas.getFontMetrics().getHeight() + padding; + int x = (int) Math.round(anchorX - width * pushX); + int y = StudioTheme.scale(12); + StudioOverlay.drawCard(canvas, x, y, width, height); + canvas.setColor(StudioTheme.TEXT); + canvas.drawString(text, x + padding, y + padding / 2 + canvas.getFontMetrics().getAscent()); + } + + private static void drawCaption(Graphics2D canvas, int width, String text) { + canvas.setFont(StudioTheme.font()); + int padding = StudioTheme.scale(10); + int captionWidth = canvas.getFontMetrics().stringWidth(text) + padding * 2; + int height = canvas.getFontMetrics().getHeight() + padding; + int x = (width - captionWidth) / 2; + int y = StudioTheme.scale(12); + StudioOverlay.drawCard(canvas, x, y, captionWidth, height); + canvas.setColor(StudioTheme.TEXT); + canvas.drawString(text, x + padding, y + padding / 2 + canvas.getFontMetrics().getAscent()); + } + + private record ChangesKey(long nowVersion, int offsetX, int offsetZ, int width, int height) { + } +} diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java b/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java index d5a730f61..33ead7e0b 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java +++ b/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java @@ -20,11 +20,12 @@ package com.volmit.iris.core.gui; import com.volmit.iris.Iris; import com.volmit.iris.core.gui.components.IrisRenderer; +import com.volmit.iris.core.gui.components.MapLabels; +import com.volmit.iris.core.gui.components.MapStyle; import com.volmit.iris.core.gui.components.RenderType; import com.volmit.iris.engine.framework.Engine; import com.volmit.iris.engine.object.IrisBiome; import com.volmit.iris.engine.object.IrisDimension; -import com.volmit.iris.engine.object.IrisRegion; import com.volmit.iris.util.format.Form; import javax.swing.JMenuItem; @@ -33,6 +34,7 @@ import javax.swing.JPopupMenu; import javax.swing.SwingUtilities; import javax.swing.Timer; import java.awt.Color; +import java.awt.Cursor; import java.awt.EventQueue; import java.awt.Graphics; import java.awt.Graphics2D; @@ -73,6 +75,7 @@ final class MapCanvas extends JPanel { private static final double DEFAULT_BLOCKS_PER_PIXEL = 4D; private static final double WHEEL_ZOOM_EXPONENT = 0.08D; private static final double KEYBOARD_ZOOM_FACTOR = 1.189207115002721D; + private static final int LEGEND_REFRESH_MILLIS = 200; private final UUID openerId; private final Listener listener; @@ -82,7 +85,9 @@ final class MapCanvas extends JPanel { private final Timer hoverTimer; private final Timer markerTimer; private final Timer notificationTimer; + private final Timer legendTimer; private final Map notifications = new LinkedHashMap<>(); + private final HotloadComparison comparison = new HotloadComparison(); private Engine engine; private IrisRenderer renderer; @@ -90,13 +95,17 @@ final class MapCanvas extends JPanel { private VisionRenderController.Frame renderFrame; private BufferedImage tileLayer; private boolean tileLayerStale = true; + private long tileLayerVersion; + private MapStyle style = MapStyle.PLAIN; private RenderType currentType = RenderType.BIOME; private String viewLabel = MapView.BIOMES.label(); private List players = List.of(); private List entities = List.of(); - private HoverInfo hoverInfo; + private QuickHover quickHover; + private MapHoverProbe.Details hoverDetails; private Point cursor; private Point dragOrigin; + private boolean draggingDivider; private double centerX; private double centerZ; private double heightMaximum; @@ -135,6 +144,7 @@ final class MapCanvas extends JPanel { }); this.hoverTimer = singleShotTimer(90, this::requestHover); + this.legendTimer = singleShotTimer(LEGEND_REFRESH_MILLIS, this::publishLegend); this.markerTimer = new Timer(650, event -> refreshMarkers()); this.markerTimer.setCoalesce(true); this.notificationTimer = new Timer(200, event -> expireNotifications()); @@ -172,6 +182,18 @@ final class MapCanvas extends JPanel { return follow; } + MapStyle style() { + return style; + } + + HotloadComparison.Mode comparisonMode() { + return comparison.hasBefore() ? comparison.mode() : HotloadComparison.Mode.OFF; + } + + boolean hasBeforePicture() { + return comparison.hasBefore(); + } + void start() { refreshMarkers(); requestRender(); @@ -195,6 +217,10 @@ final class MapCanvas extends JPanel { requestRender(); } + String legendTitle() { + return currentType == RenderType.REGION ? "Regions in view" : "Biomes in view"; + } + // Tiles keep loading in the background only for the view on screen; a hidden canvas stops asking for new ones. void setActive(boolean nextActive) { active = nextActive; @@ -211,12 +237,117 @@ final class MapCanvas extends JPanel { notifyUser("No active Iris engine"); return; } + boolean captured = captureBeforePicture(); renderer = new IrisRenderer(engine); overlay = new VisionOverlay(engine, openerId); contentRevision++; captureHeightRange(); requestRender(); - notifyUser("Refreshing"); + notifyUser(captured && comparison.mode() == HotloadComparison.Mode.OFF + ? "Refreshing · press C to compare with before" + : "Refreshing"); + listener.onTogglesChanged(); + } + + // Only a finished picture is worth comparing against; a half drawn one would show its gaps as changes. + private boolean captureBeforePicture() { + if (tileLayer == null || controller.progress(renderFrame).completion() < 1D) { + comparison.forget(); + return false; + } + comparison.capture(currentTileLayer(), currentScene()); + return true; + } + + void toggleShading() { + applyStyle(style.withShading(!style.shading())); + notifyUser(style.shading() ? "Terrain shading on" : "Terrain shading off"); + listener.onTogglesChanged(); + } + + void toggleRegionBorders() { + applyStyle(style.withRegionBorders(!style.regionBorders())); + notifyUser(style.regionBorders() ? "Region borders on" : "Region borders off"); + listener.onTogglesChanged(); + } + + /** + * Highlights the biome or region, or clears the highlight when it is the one already highlighted. + */ + void toggleHighlight(MapLabels.Label label) { + if (label == null) { + return; + } + MapStyle.Highlight current = style.highlight(); + if (current != null && current.kind() == label.kind() && current.key().equals(label.key())) { + clearHighlight(); + return; + } + applyStyle(style.withHighlight(new MapStyle.Highlight(label.kind(), label.key(), label.name()))); + notifyUser("Highlighting " + label.name() + " · Esc clears"); + } + + void highlightUnderCursor() { + VisionRenderController.TilePixel pixel = cursor == null ? null : tilePixelAt(cursor); + if (pixel == null) { + notifyUser("Point at the map to highlight what is there"); + return; + } + toggleHighlight(pixel.subject()); + } + + /** + * Esc steps back one layer of emphasis: the highlight first, then the comparison, then the help. + */ + void dismissEmphasis() { + if (style.highlight() != null) { + clearHighlight(); + } else if (comparisonMode() != HotloadComparison.Mode.OFF) { + showComparison(HotloadComparison.Mode.OFF); + } else if (help) { + toggleHelp(); + } + } + + private void clearHighlight() { + applyStyle(style.withHighlight(null)); + notifyUser("Highlight cleared"); + } + + void cycleComparison() { + showComparison(comparisonMode().next()); + } + + void showComparison(HotloadComparison.Mode mode) { + if (!comparison.hasBefore()) { + notifyUser("Refresh or hotload the pack first, then compare with before"); + listener.onTogglesChanged(); + return; + } + comparison.setMode(mode); + notifyUser(switch (mode) { + case OFF -> "Comparison off"; + case SWIPE -> "Drag the divider to compare before and now"; + case CHANGES -> "Showing what changed in the hotload"; + }); + repaint(); + listener.onTogglesChanged(); + } + + private void applyStyle(MapStyle next) { + boolean heightsWereNeeded = needsHeights(); + style = next; + if (needsHeights() != heightsWereNeeded) { + requestRender(); + } else { + tileLayerStale = true; + repaint(); + } + scheduleLegendUpdate(); + } + + private boolean needsHeights() { + return style.shading() || currentType == RenderType.HEIGHT; } void toggleGrid() { @@ -289,6 +420,7 @@ final class MapCanvas extends JPanel { } closed = true; hoverTimer.stop(); + legendTimer.stop(); markerTimer.stop(); notificationTimer.stop(); GuiHost.unregisterHotloadHook(hotloadHook); @@ -308,6 +440,10 @@ final class MapCanvas extends JPanel { public void mouseMoved(MouseEvent event) { cursor = event.getPoint(); setDetailedHover(event.isShiftDown()); + setCursor(comparison.isNearDivider(event.getX(), getWidth()) + ? Cursor.getPredefinedCursor(Cursor.E_RESIZE_CURSOR) + : Cursor.getDefaultCursor()); + showQuickHover(); hoverTimer.restart(); refreshStatus(); } @@ -315,6 +451,11 @@ final class MapCanvas extends JPanel { @Override public void mouseDragged(MouseEvent event) { cursor = event.getPoint(); + if (draggingDivider) { + comparison.moveDivider(event.getX(), getWidth()); + repaint(); + return; + } if (dragOrigin == null) { return; } @@ -322,7 +463,7 @@ final class MapCanvas extends JPanel { centerZ += (dragOrigin.y - event.getY()) * blocksPerPixel; dragOrigin = event.getPoint(); stopFollowing(); - hoverInfo = null; + clearHover(); requestRender(); } @@ -339,6 +480,8 @@ final class MapCanvas extends JPanel { cursor = event.getPoint(); if (event.isPopupTrigger()) { showContextMenu(event); + } else if (SwingUtilities.isLeftMouseButton(event) && comparison.isNearDivider(event.getX(), getWidth())) { + draggingDivider = true; } else if (SwingUtilities.isLeftMouseButton(event)) { dragOrigin = event.getPoint(); } @@ -347,6 +490,7 @@ final class MapCanvas extends JPanel { @Override public void mouseReleased(MouseEvent event) { dragOrigin = null; + draggingDivider = false; if (event.isPopupTrigger()) { showContextMenu(event); } @@ -375,7 +519,7 @@ final class MapCanvas extends JPanel { @Override public void mouseExited(MouseEvent event) { cursor = null; - hoverInfo = null; + clearHover(); hoverTimer.stop(); refreshStatus(); repaint(); @@ -391,6 +535,7 @@ final class MapCanvas extends JPanel { int blockX = floorWorldCoordinate(screenToWorldX(point.x)); int blockZ = floorWorldCoordinate(screenToWorldZ(point.y)); JPopupMenu menu = new JPopupMenu(); + addHighlightItems(menu, tilePixelAt(point)); menu.add(menuItem("Show palettes here", () -> showPalettesAt(point))); menu.add(menuItem("Teleport here", () -> teleportTo(point))); menu.add(menuItem(currentType == RenderType.REGION ? "Open region file" : "Open biome file", () -> openInEditor(point))); @@ -399,6 +544,30 @@ final class MapCanvas extends JPanel { menu.show(this, point.x, point.y); } + private void addHighlightItems(JPopupMenu menu, VisionRenderController.TilePixel pixel) { + MapLabels.Label subject = pixel == null ? null : pixel.subject(); + MapLabels.Label region = pixel == null ? null : pixel.region(); + if (subject != null) { + menu.add(menuItem(highlightText(subject), () -> toggleHighlight(subject))); + } + if (region != null && subject != null && subject.kind() != MapLabels.Kind.REGION) { + menu.add(menuItem(highlightText(region), () -> toggleHighlight(region))); + } + if (style.highlight() != null) { + menu.add(menuItem("Clear highlight", this::clearHighlight)); + } + if (menu.getComponentCount() > 0) { + menu.addSeparator(); + } + } + + private String highlightText(MapLabels.Label label) { + MapStyle.Highlight current = style.highlight(); + boolean highlighted = current != null && current.kind() == label.kind() && current.key().equals(label.key()); + String kind = label.kind() == MapLabels.Kind.REGION ? "region " : ""; + return (highlighted ? "Stop highlighting " : "Highlight ") + kind + label.name(); + } + private static JMenuItem menuItem(String text, Runnable action) { JMenuItem item = new JMenuItem(text); item.addActionListener(event -> action.run()); @@ -459,7 +628,17 @@ final class MapCanvas extends JPanel { // The display server gets one finished picture per paint: handing it every tile separately made the map lag. private void renderTiles(Graphics2D canvas) { - canvas.drawImage(currentTileLayer(), 0, 0, null); + BufferedImage layer = currentTileLayer(); + if (!comparison.isShowing()) { + canvas.drawImage(layer, 0, 0, null); + return; + } + boolean complete = controller.progress(renderFrame).completion() >= 1D; + comparison.paint(canvas, layer, tileLayerVersion, currentScene(), complete); + } + + private HotloadComparison.Scene currentScene() { + return new HotloadComparison.Scene(currentType, style, blocksPerPixel, centerX, centerZ); } private BufferedImage currentTileLayer() { @@ -478,6 +657,7 @@ final class MapCanvas extends JPanel { layer.dispose(); } tileLayerStale = false; + tileLayerVersion++; } return tileLayer; } @@ -498,9 +678,8 @@ final class MapCanvas extends JPanel { int screenY = (int) Math.round(worldToScreenZ(worldZ)); int tileWidth = Math.max(1, (int) Math.round(worldToScreenX(worldX + frameTileSpan)) - screenX); int tileHeight = Math.max(1, (int) Math.round(worldToScreenZ(worldZ + frameTileSpan)) - screenY); - BufferedImage image = tile.image(); - if (image != null) { - canvas.drawImage(image, screenX, screenY, tileWidth, tileHeight, null); + if (tile.tile() != null) { + canvas.drawImage(tile.tile().image(style), screenX, screenY, tileWidth, tileHeight, null); continue; } canvas.setColor(((tile.tileX() ^ tile.tileZ()) & 1L) == 0L ? TILE_EMPTY_A : TILE_EMPTY_B); @@ -509,7 +688,7 @@ final class MapCanvas extends JPanel { } } - private static void drawFallback(Graphics2D canvas, VisionRenderController.Fallback fallback, int screenX, int screenY, + private void drawFallback(Graphics2D canvas, VisionRenderController.Fallback fallback, int screenX, int screenY, int tileWidth, int tileHeight) { if (fallback == null) { return; @@ -519,7 +698,7 @@ final class MapCanvas extends JPanel { int top = screenY + (int) Math.round(piece.offsetZ() * tileHeight); int right = screenX + (int) Math.round((piece.offsetX() + piece.size()) * tileWidth); int bottom = screenY + (int) Math.round((piece.offsetZ() + piece.size()) * tileHeight); - canvas.drawImage(piece.image(), left, top, right, bottom, + canvas.drawImage(piece.tile().image(style), left, top, right, bottom, piece.sourceX(), piece.sourceZ(), piece.sourceX() + piece.sourceSize(), piece.sourceZ() + piece.sourceSize(), null); } } @@ -651,30 +830,21 @@ final class MapCanvas extends JPanel { } private void renderHover(Graphics2D canvas) { - HoverInfo info = hoverInfo; - if (info == null) { + QuickHover quick = quickHover; + if (quick == null || draggingDivider) { return; } - ArrayList lines = new ArrayList<>(); - lines.add(info.biomeName()); - lines.add(info.regionName()); - lines.add("Block " + ((int) info.worldX()) + ", " + ((int) info.worldZ())); - if (detailedHover) { - lines.add("Chunk " + ((int) info.worldX() >> 4) + ", " + ((int) info.worldZ() >> 4)); - lines.add("Region file " + ((int) info.worldX() >> 9) + ", " + ((int) info.worldZ() >> 9)); - lines.add("Key " + info.biomeKey()); - lines.add("File " + info.biomeFile()); - } else { - lines.add("Hold Shift for more, double-click for palettes"); - } - int screenX = (int) Math.round(worldToScreenX(info.worldX())) + StudioTheme.scale(16); - int screenY = (int) Math.round(worldToScreenZ(info.worldZ())); + MapHoverProbe.Details details = hoverDetails; + List lines = MapHoverCard.lines(quick.subject(), quick.region(), details, currentType, detailedHover, + quick.worldX(), quick.worldZ()); + int screenX = (int) Math.round(worldToScreenX(quick.worldX())) + StudioTheme.scale(16); + int screenY = (int) Math.round(worldToScreenZ(quick.worldZ())); drawCard(canvas, screenX, screenY, 0D, 0D, lines); } private void renderHelp(Graphics2D canvas) { List keys = List.of("Drag", "Wheel / + -", "\\", "Double-click", "Right-click", "Ctrl+Click", "Alt+Click", - "Shift", "R", "F", "G", "E", "M", "1 to 0, P", "/"); + "Shift", "H", "Esc", "S", "B", "C", "R", "F", "G", "E", "M", "1 to 0, P", "/"); List descriptions = List.of( "Move the map", "Zoom in and out", @@ -684,6 +854,11 @@ final class MapCanvas extends JPanel { "Teleport there", "Open the biome file", "More details while hovering", + "Highlight what is under the cursor", + "Clear the highlight", + "Terrain shading", + "Region borders", + "Compare with before the last hotload", "Redraw the map", "Follow your player", "Chunk and region grid", @@ -776,6 +951,28 @@ final class MapCanvas extends JPanel { tileLayerStale = true; repaint(); refreshStatus(); + scheduleLegendUpdate(); + if (quickHover != null && quickHover.subject() == null) { + showQuickHover(); + } + } + + // At most one count per interval however fast tiles arrive, so the legend stays live without costing frames. + private void scheduleLegendUpdate() { + if (!legendTimer.isRunning()) { + legendTimer.start(); + } + } + + private void publishLegend() { + if (closed || !active) { + return; + } + IrisRenderer legendRenderer = renderer; + RenderType type = currentType; + List shares = MapCensus.count(renderFrame, screenToWorldX(0D), screenToWorldZ(0D), + screenToWorldX(getWidth()), screenToWorldZ(getHeight()), label -> legendRenderer.legendColor(label, type)); + listener.onLegendChanged(legendTitle(), shares, style.highlight()); } private void requestRender() { @@ -784,7 +981,7 @@ final class MapCanvas extends JPanel { repaint(); return; } - hoverInfo = null; + clearHover(); VisionRenderController.RenderSpec spec = new VisionRenderController.RenderSpec( renderer, currentType, @@ -793,11 +990,13 @@ final class MapCanvas extends JPanel { centerZ, blocksPerPixel, getWidth(), - getHeight() + getHeight(), + needsHeights() ); renderFrame = controller.request(spec); repaint(); refreshStatus(); + scheduleLegendUpdate(); } private void refreshStatus() { @@ -894,25 +1093,38 @@ final class MapCanvas extends JPanel { double worldZ = screenToWorldZ(point.y); controller.submitProbe( frame, - () -> sampleHover(probeEngine, worldX, worldZ), - info -> { - hoverInfo = info; - repaint(); + () -> MapHoverProbe.sample(probeEngine, worldX, worldZ), + details -> { + QuickHover quick = quickHover; + if (quick != null && quick.worldX() == details.worldX() && quick.worldZ() == details.worldZ()) { + hoverDetails = details; + repaint(); + } } ); } - private static HoverInfo sampleHover(Engine engine, double worldX, double worldZ) { - IrisBiome biome = engine.getComplex().getBaseBiomeStream().get(worldX, worldZ); - IrisRegion region = engine.getComplex().getRegionStream().get(worldX, worldZ); - return new HoverInfo( - worldX, - worldZ, - biome.getName(), - region.getName(), - biome.getLoadKey(), - String.valueOf(biome.getLoadFile()) - ); + // The drawn tiles already know the biome and region under the cursor, so the card follows the mouse without waiting. + private void showQuickHover() { + Point point = cursor; + if (point == null || dragOrigin != null) { + return; + } + double worldX = screenToWorldX(point.x); + double worldZ = screenToWorldZ(point.y); + VisionRenderController.TilePixel pixel = tilePixelAt(point); + quickHover = new QuickHover(worldX, worldZ, pixel == null ? null : pixel.subject(), pixel == null ? null : pixel.region()); + repaint(); + } + + private void clearHover() { + quickHover = null; + hoverDetails = null; + } + + private VisionRenderController.TilePixel tilePixelAt(Point point) { + VisionRenderController.Frame frame = renderFrame; + return frame == null ? null : frame.pixelAt(screenToWorldX(point.x), screenToWorldZ(point.y)); } private void stopFollowing() { @@ -963,7 +1175,7 @@ final class MapCanvas extends JPanel { centerZ = next.centerZ(); blocksPerPixel = next.blocksPerPixel(); stopFollowing(); - hoverInfo = null; + clearHover(); requestRender(); } @@ -1017,15 +1229,10 @@ final class MapCanvas extends JPanel { void onTogglesChanged(); void onShowPalettesAt(double worldX, double worldZ); + + void onLegendChanged(String title, List shares, MapStyle.Highlight highlight); } - private record HoverInfo( - double worldX, - double worldZ, - String biomeName, - String regionName, - String biomeKey, - String biomeFile - ) { + private record QuickHover(double worldX, double worldZ, MapLabels.Label subject, MapLabels.Label region) { } } diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapCensus.java b/core/src/main/java/com/volmit/iris/core/gui/MapCensus.java new file mode 100644 index 000000000..7bba7aa39 --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/MapCensus.java @@ -0,0 +1,109 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.volmit.iris.core.gui; + +import com.volmit.iris.core.gui.components.MapLabels; +import com.volmit.iris.core.gui.components.MapTile; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.ToIntFunction; + +/** + * Counts how much of the visible map each biome or region covers, from the tiles already drawn. + */ +final class MapCensus { + private static final long MAXIMUM_COUNTED_PIXELS = 250_000L; + + private MapCensus() { + } + + static List count(VisionRenderController.Frame frame, double minimumX, double minimumZ, double maximumX, + double maximumZ, ToIntFunction colorOf) { + if (frame == null) { + return List.of(); + } + double blocksPerPixel = frame.renderBlocksPerPixel(); + int stride = countingStride(maximumX - minimumX, maximumZ - minimumZ, blocksPerPixel); + double tileSpan = VisionRenderController.TILE_PIXELS * blocksPerPixel; + Map counts = new HashMap<>(); + MapLabels labels = null; + long counted = 0L; + for (VisionRenderController.VisibleTile visible : frame.tiles()) { + MapTile tile = visible.tile(); + if (tile == null) { + continue; + } + labels = tile.labels(); + double originX = visible.tileX() * tileSpan; + double originZ = visible.tileZ() * tileSpan; + int firstX = firstPixel(minimumX - originX, blocksPerPixel, stride, visible.tileX()); + int firstZ = firstPixel(minimumZ - originZ, blocksPerPixel, stride, visible.tileZ()); + int endX = endPixel(maximumX - originX, blocksPerPixel, tile.size()); + int endZ = endPixel(maximumZ - originZ, blocksPerPixel, tile.size()); + for (int pixelZ = firstZ; pixelZ < endZ; pixelZ += stride) { + for (int pixelX = firstX; pixelX < endX; pixelX += stride) { + counts.computeIfAbsent(tile.subjectAt(pixelX, pixelZ), id -> new long[1])[0]++; + counted++; + } + } + } + return shares(counts, labels, counted, colorOf); + } + + private static List shares(Map counts, MapLabels labels, long counted, + ToIntFunction colorOf) { + if (labels == null || counted == 0L) { + return List.of(); + } + List shares = new ArrayList<>(counts.size()); + for (Map.Entry entry : counts.entrySet()) { + MapLabels.Label label = labels.label(entry.getKey()); + if (label != null) { + shares.add(new Share(label, colorOf.applyAsInt(label), entry.getValue()[0] / (double) counted)); + } + } + shares.sort(Comparator.comparingDouble(Share::fraction).reversed().thenComparing(share -> share.label().name())); + return List.copyOf(shares); + } + + // Counting every pixel of a big screen is wasted work; an evenly spread sample gives the same percentages. + private static int countingStride(double widthBlocks, double heightBlocks, double blocksPerPixel) { + double pixels = (widthBlocks / blocksPerPixel) * (heightBlocks / blocksPerPixel); + return Math.max(1, (int) Math.ceil(Math.sqrt(pixels / MAXIMUM_COUNTED_PIXELS))); + } + + // Starting on the global stride grid keeps neighbouring tiles from sampling the same columns twice as densely. + private static int firstPixel(double offsetBlocks, double blocksPerPixel, int stride, long tileIndex) { + int first = Math.max(0, (int) Math.ceil(offsetBlocks / blocksPerPixel)); + long globalPixel = tileIndex * VisionRenderController.TILE_PIXELS + first; + int misalignment = (int) Math.floorMod(globalPixel, (long) stride); + return misalignment == 0 ? first : first + stride - misalignment; + } + + private static int endPixel(double offsetBlocks, double blocksPerPixel, int size) { + return Math.min(size, (int) Math.ceil(offsetBlocks / blocksPerPixel)); + } + + record Share(MapLabels.Label label, int color, double fraction) { + } +} diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapHoverCard.java b/core/src/main/java/com/volmit/iris/core/gui/MapHoverCard.java new file mode 100644 index 000000000..e4cf8f135 --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/MapHoverCard.java @@ -0,0 +1,112 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.volmit.iris.core.gui; + +import com.volmit.iris.core.gui.components.MapLabels; +import com.volmit.iris.core.gui.components.RenderType; +import com.volmit.iris.core.loader.IrisRegistrant; +import com.volmit.iris.engine.object.IrisBiome; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/** + * The text of the card that follows the cursor: what the drawn map shows there at once, and the rest once probed. + */ +final class MapHoverCard { + private MapHoverCard() { + } + + /** + * @param subject the biome, or region in the region view, the drawn tile shows; null while that tile is drawing + * @param details the probed facts about the spot, or null until they arrive + * @param detailed whether Shift is held, which adds keys and files + */ + static List lines(MapLabels.Label subject, MapLabels.Label region, MapHoverProbe.Details details, + RenderType type, boolean detailed, double worldX, double worldZ) { + List lines = new ArrayList<>(); + lines.add(title(subject, details)); + if (type != RenderType.REGION) { + String regionName = region != null ? region.name() : details != null ? details.region().getName() : null; + if (regionName != null) { + lines.add("Region " + regionName); + } + } + if (details != null) { + addTerrain(lines, details, type); + } + int blockX = MapCanvas.floorWorldCoordinate(worldX); + int blockZ = MapCanvas.floorWorldCoordinate(worldZ); + lines.add("Block " + blockX + ", " + blockZ + " · chunk " + (blockX >> 4) + ", " + (blockZ >> 4)); + if (!detailed) { + lines.add("Shift for keys and files · H highlights"); + return lines; + } + addSource(lines, subject); + if (subject != null && subject.kind() != MapLabels.Kind.REGION) { + addSource(lines, region); + } + lines.add("Region file r." + (blockX >> 9) + "." + (blockZ >> 9) + ".mca"); + return lines; + } + + private static String title(MapLabels.Label subject, MapHoverProbe.Details details) { + if (subject != null) { + return subject.name(); + } + if (details != null && details.surfaceBiome() != null) { + return details.surfaceBiome().getName(); + } + return "Drawing…"; + } + + private static void addTerrain(List lines, MapHoverProbe.Details details, RenderType type) { + lines.add("Height " + Math.round(details.height()) + " · sea level " + Math.round(details.fluidHeight())); + String slope = "slope " + Math.round(details.slopeDegrees()) + "°"; + lines.add(details.terrainKind() == null ? capitalize(slope) : details.terrainKind() + " · " + slope); + IrisBiome surface = details.surfaceBiome(); + IrisBiome cave = details.caveBiome(); + if (type == RenderType.CAVE_LAND && surface != null) { + lines.add("Surface biome " + surface.getName()); + } else if (type != RenderType.CAVE_LAND && cave != null && cave != surface) { + lines.add("Cave biome " + cave.getName()); + } + if (details.vanillaBiome() != null) { + lines.add("Minecraft biome " + details.vanillaBiome().replace('_', ' ')); + } + } + + private static void addSource(List lines, MapLabels.Label label) { + if (label == null || !(label.subject() instanceof IrisRegistrant registrant)) { + return; + } + String kind = label.kind() == MapLabels.Kind.REGION ? "Region" : "Biome"; + lines.add(kind + " key " + label.key()); + File file = registrant.getLoadFile(); + if (file != null) { + File folder = file.getParentFile(); + lines.add(kind + " file " + (folder == null ? "" : folder.getName() + "/") + file.getName()); + } + } + + private static String capitalize(String text) { + return Character.toUpperCase(text.charAt(0)) + text.substring(1); + } +} diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapHoverProbe.java b/core/src/main/java/com/volmit/iris/core/gui/MapHoverProbe.java new file mode 100644 index 000000000..36b0d1c65 --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/MapHoverProbe.java @@ -0,0 +1,90 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.volmit.iris.core.gui; + +import com.volmit.iris.engine.IrisComplex; +import com.volmit.iris.engine.framework.Engine; +import com.volmit.iris.engine.object.InferredType; +import com.volmit.iris.engine.object.IrisBiome; +import com.volmit.iris.engine.object.IrisDimension; +import com.volmit.iris.engine.object.IrisRegion; +import org.bukkit.block.Biome; + +import java.util.Locale; + +/** + * Looks up everything the hover card can tell about one spot of the world; runs off the Swing thread. + */ +final class MapHoverProbe { + // IrisComplex measures slope as the height change over this many blocks. + private static final double SLOPE_STREAM_RANGE_BLOCKS = 3D; + + private MapHoverProbe() { + } + + static Details sample(Engine engine, double worldX, double worldZ) { + IrisComplex complex = engine.getComplex(); + IrisBiome surfaceBiome = complex.getBaseBiomeStream().get(worldX, worldZ); + IrisBiome caveBiome = complex.getCaveBiomeStream().get(worldX, worldZ); + IrisRegion region = complex.getRegionStream().get(worldX, worldZ); + InferredType terrainKind = complex.getBridgeStream().get(worldX, worldZ); + Biome vanillaBiome = complex.getTrueBiomeDerivativeStream().get(worldX, worldZ); + double height = complex.getHeightStream().getDouble(worldX, worldZ); + double slope = complex.getSlopeStream().getDouble(worldX, worldZ); + IrisDimension dimension = engine.getDimension(); + return new Details( + worldX, + worldZ, + surfaceBiome, + caveBiome, + region, + terrainKindName(surfaceBiome, terrainKind), + vanillaBiome == null ? null : vanillaBiome.getKey().getKey(), + height, + dimension == null ? 0D : dimension.getFluidHeight(), + Math.toDegrees(Math.atan(slope / SLOPE_STREAM_RANGE_BLOCKS)) + ); + } + + // A shore biome sits on land the continent stream calls land or sea, so the biome's own type says it best. + private static String terrainKindName(IrisBiome surfaceBiome, InferredType continentKind) { + InferredType kind = surfaceBiome != null && surfaceBiome.getInferredType() != null + ? surfaceBiome.getInferredType() + : continentKind; + if (kind == null) { + return null; + } + String name = kind.name().toLowerCase(Locale.ROOT); + return Character.toUpperCase(name.charAt(0)) + name.substring(1); + } + + record Details( + double worldX, + double worldZ, + IrisBiome surfaceBiome, + IrisBiome caveBiome, + IrisRegion region, + String terrainKind, + String vanillaBiome, + double height, + double fluidHeight, + double slopeDegrees + ) { + } +} diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapLegendPanel.java b/core/src/main/java/com/volmit/iris/core/gui/MapLegendPanel.java new file mode 100644 index 000000000..e3e8b10f8 --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/MapLegendPanel.java @@ -0,0 +1,204 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.volmit.iris.core.gui; + +import com.volmit.iris.core.gui.components.MapStyle; + +import javax.swing.BorderFactory; +import javax.swing.DefaultListCellRenderer; +import javax.swing.DefaultListModel; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.ListSelectionModel; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.RenderingHints; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.List; +import java.util.Locale; +import java.util.function.Consumer; + +/** + * The biomes or regions on screen with how much of it each covers; clicking one highlights it on the map. + */ +final class MapLegendPanel extends JPanel { + private static final long serialVersionUID = 4471297416386034128L; + private static final int PANEL_WIDTH = 250; + private static final int ROW_HEIGHT = 26; + private static final int SWATCH_SIZE = 12; + private static final int SWATCH_GAP = 8; + private static final int ROW_PADDING = 10; + private static final int PERCENT_WIDTH = 42; + private static final String DRAWING_MESSAGE = "Drawing the map…"; + + private final JLabel title = new JLabel(); + private final DefaultListModel rows = new DefaultListModel<>(); + private final JList list = new JList<>(rows); + private MapStyle.Highlight highlight; + + MapLegendPanel(Consumer onRowClicked) { + super(new BorderLayout()); + setPreferredSize(StudioTheme.scaledSize(PANEL_WIDTH, 0)); + setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, StudioTheme.SEPARATOR)); + setBackground(StudioTheme.PANEL_BACKGROUND); + + title.setFont(StudioTheme.sectionFont()); + title.setForeground(StudioTheme.MUTED_TEXT); + title.setBorder(StudioTheme.emptyBorder(10, 16, 6, 16)); + add(title, BorderLayout.NORTH); + + list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + list.setFocusable(false); + list.setFixedCellHeight(StudioTheme.scale(ROW_HEIGHT)); + list.setCellRenderer(new ShareRenderer()); + list.setBackground(StudioTheme.PANEL_BACKGROUND); + list.setBorder(StudioTheme.emptyBorder(0, 6, 4, 6)); + list.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent event) { + MapCensus.Share share = shareAt(event.getPoint()); + if (share != null) { + onRowClicked.accept(share); + } + } + }); + add(StudioToolbar.scroll(list), BorderLayout.CENTER); + + JLabel hint = new JLabel("Click to highlight · Esc clears"); + hint.setFont(StudioTheme.smallFont()); + hint.setForeground(StudioTheme.MUTED_TEXT); + hint.setBorder(StudioTheme.emptyBorder(6, 16, 8, 16)); + add(hint, BorderLayout.SOUTH); + } + + void show(String subjectTitle, List shares, MapStyle.Highlight nextHighlight) { + title.setText(subjectTitle.toUpperCase(Locale.ROOT)); + highlight = nextHighlight; + if (!sameRows(shares)) { + rows.clear(); + rows.addAll(shares); + } + list.repaint(); + } + + private boolean sameRows(List shares) { + if (rows.size() != shares.size()) { + return false; + } + for (int index = 0; index < shares.size(); index++) { + if (!rows.get(index).equals(shares.get(index))) { + return false; + } + } + return true; + } + + private MapCensus.Share shareAt(Point point) { + int index = list.locationToIndex(point); + if (index < 0) { + return null; + } + Rectangle bounds = list.getCellBounds(index, index); + return bounds != null && bounds.contains(point) ? rows.get(index) : null; + } + + private boolean isHighlighted(MapCensus.Share share) { + return highlight != null + && highlight.kind() == share.label().kind() + && highlight.key().equals(share.label().key()); + } + + @Override + protected void paintChildren(Graphics graphics) { + super.paintChildren(graphics); + if (!rows.isEmpty()) { + return; + } + Graphics2D canvas = (Graphics2D) graphics.create(); + try { + canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + canvas.setFont(StudioTheme.font()); + canvas.setColor(StudioTheme.MUTED_TEXT); + canvas.drawString(DRAWING_MESSAGE, StudioTheme.scale(16), + title.getHeight() + StudioTheme.scale(4) + canvas.getFontMetrics().getAscent()); + } finally { + canvas.dispose(); + } + } + + private final class ShareRenderer extends DefaultListCellRenderer { + private static final long serialVersionUID = -2350212934617052618L; + private Color swatch = Color.BLACK; + private String percent = ""; + + @Override + public Component getListCellRendererComponent(JList owner, Object value, int index, boolean selected, boolean focused) { + MapCensus.Share share = (MapCensus.Share) value; + super.getListCellRendererComponent(owner, share.label().name(), index, isHighlighted(share), false); + setFont(StudioTheme.font()); + setBorder(StudioTheme.emptyBorder(0, ROW_PADDING + SWATCH_SIZE + SWATCH_GAP, 0, ROW_PADDING + PERCENT_WIDTH)); + setToolTipText(share.label().key()); + swatch = new Color(share.color()); + percent = formatPercent(share.fraction()); + return this; + } + + @Override + protected void paintComponent(Graphics graphics) { + super.paintComponent(graphics); + Graphics2D canvas = (Graphics2D) graphics.create(); + try { + canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + int size = StudioTheme.scale(SWATCH_SIZE); + int swatchX = StudioTheme.scale(ROW_PADDING); + int swatchY = (getHeight() - size) / 2; + int arc = StudioTheme.scale(4); + canvas.setColor(swatch); + canvas.fillRoundRect(swatchX, swatchY, size, size, arc, arc); + canvas.setColor(StudioTheme.CONTROL_BORDER); + canvas.drawRoundRect(swatchX, swatchY, size - 1, size - 1, arc, arc); + canvas.setFont(StudioTheme.smallFont()); + canvas.setColor(StudioTheme.SECONDARY_TEXT); + int textWidth = canvas.getFontMetrics().stringWidth(percent); + int baseline = (getHeight() + canvas.getFontMetrics().getAscent() - canvas.getFontMetrics().getDescent()) / 2; + canvas.drawString(percent, getWidth() - StudioTheme.scale(ROW_PADDING) - textWidth, baseline); + } finally { + canvas.dispose(); + } + } + } + + private static String formatPercent(double fraction) { + double percent = fraction * 100D; + if (percent < 0.1D) { + return "<0.1%"; + } + return percent < 10D + ? String.format(Locale.ROOT, "%.1f%%", percent) + : String.format(Locale.ROOT, "%.0f%%", percent); + } +} 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 445176988..d23b7b6f8 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 @@ -18,6 +18,8 @@ package com.volmit.iris.core.gui; +import com.volmit.iris.core.gui.components.MapStyle; + import javax.swing.AbstractAction; import javax.swing.BorderFactory; import javax.swing.Box; @@ -30,6 +32,7 @@ import javax.swing.JSplitPane; import javax.swing.JToggleButton; import javax.swing.JToolBar; import javax.swing.KeyStroke; +import javax.swing.SwingConstants; import javax.swing.text.JTextComponent; import java.awt.BorderLayout; import java.awt.CardLayout; @@ -68,6 +71,13 @@ public final class MapWindow implements MapCanvas.Listener { private final JToggleButton gridToggle = StudioToolbar.toggle("Grid", "Show chunk and region borders (G)"); private final JToggleButton entitiesToggle = StudioToolbar.toggle("Entities", "Show mobs and other entities (E)"); private final JToggleButton followToggle = StudioToolbar.toggle("Follow", "Keep your player in the middle (F)"); + private final JToggleButton shadingToggle = StudioToolbar.toggle("Shading", "Shade hills and valleys on every view (S)"); + private final JToggleButton bordersToggle = StudioToolbar.toggle("Borders", "Outline the regions on every view (B)"); + private final JToggleButton swipeToggle = StudioToolbar.toggle("Before / after", + "Split the map between before and after the last hotload or refresh (C)"); + private final JToggleButton changesToggle = StudioToolbar.toggle("Changes", + "Fade out everything the last hotload or refresh left the same (C twice)"); + private final MapLegendPanel legendPanel; private NoiseExplorerGUI paletteExplorer; private MapView currentView; @@ -80,6 +90,7 @@ public final class MapWindow implements MapCanvas.Listener { this.canvas = new MapCanvas(start.engine(), start.playerId(), this); this.viewList = new MapViewList(this::showView); this.viewListScroll = StudioToolbar.scroll(viewList); + this.legendPanel = new MapLegendPanel(share -> canvas.toggleHighlight(share.label())); canvas.centerOn(start.centerX(), start.centerZ()); buildFrame(); } @@ -136,9 +147,21 @@ public final class MapWindow implements MapCanvas.Listener { mapControls.add(gridToggle); mapControls.add(entitiesToggle); mapControls.add(followToggle); + mapControls.add(toolbarSeparator()); + mapControls.add(shadingToggle); + mapControls.add(bordersToggle); + mapControls.add(toolbarSeparator()); + mapControls.add(swipeToggle); + mapControls.add(changesToggle); gridToggle.addActionListener(event -> canvas.toggleGrid()); entitiesToggle.addActionListener(event -> canvas.toggleEntities()); followToggle.addActionListener(event -> canvas.toggleFollow()); + shadingToggle.addActionListener(event -> canvas.toggleShading()); + bordersToggle.addActionListener(event -> canvas.toggleRegionBorders()); + swipeToggle.addActionListener(event -> canvas.showComparison( + swipeToggle.isSelected() ? HotloadComparison.Mode.SWIPE : HotloadComparison.Mode.OFF)); + changesToggle.addActionListener(event -> canvas.showComparison( + changesToggle.isSelected() ? HotloadComparison.Mode.CHANGES : HotloadComparison.Mode.OFF)); mapControls.forEach(toolbar::add); paletteControls.add(StudioToolbar.button("Reset view", "Go back to the starting position and zoom (R)", this::resetPaletteView)); @@ -151,6 +174,12 @@ public final class MapWindow implements MapCanvas.Listener { return toolbar; } + private static JComponent toolbarSeparator() { + JToolBar.Separator separator = new JToolBar.Separator(); + separator.setOrientation(SwingConstants.VERTICAL); + return separator; + } + private void showView(MapView view) { if (view == null || view == currentView) { return; @@ -179,6 +208,7 @@ public final class MapWindow implements MapCanvas.Listener { setControlsVisible(paletteControls, false); useSidebar(viewListScroll); inspectorSlot.removeAll(); + inspectorSlot.add(legendPanel, BorderLayout.CENTER); showCard(MAP_CARD); canvas.requestFocusInWindow(); } @@ -265,6 +295,15 @@ public final class MapWindow implements MapCanvas.Listener { gridToggle.setSelected(canvas.isGridVisible()); entitiesToggle.setSelected(canvas.areEntitiesVisible()); followToggle.setSelected(canvas.isFollowing()); + shadingToggle.setSelected(canvas.style().shading()); + bordersToggle.setSelected(canvas.style().regionBorders()); + swipeToggle.setSelected(canvas.comparisonMode() == HotloadComparison.Mode.SWIPE); + changesToggle.setSelected(canvas.comparisonMode() == HotloadComparison.Mode.CHANGES); + } + + @Override + public void onLegendChanged(String title, List shares, MapStyle.Highlight highlight) { + legendPanel.show(title, shares, highlight); } private void resetPaletteView() { @@ -297,6 +336,11 @@ public final class MapWindow implements MapCanvas.Listener { bindMap(rootPane, KeyEvent.VK_G, 0, "grid", canvas::toggleGrid); bindMap(rootPane, KeyEvent.VK_E, 0, "entities", canvas::toggleEntities); bindMap(rootPane, KeyEvent.VK_F3, 0, "debug", canvas::toggleDebug); + bindMap(rootPane, KeyEvent.VK_H, 0, "highlight", canvas::highlightUnderCursor); + bindMap(rootPane, KeyEvent.VK_ESCAPE, 0, "dismiss", canvas::dismissEmphasis); + bindMap(rootPane, KeyEvent.VK_S, 0, "shading", canvas::toggleShading); + bindMap(rootPane, KeyEvent.VK_B, 0, "borders", canvas::toggleRegionBorders); + bindMap(rootPane, KeyEvent.VK_C, 0, "compare", canvas::cycleComparison); bindMap(rootPane, KeyEvent.VK_EQUALS, 0, "zoom-in", canvas::zoomIn); bindMap(rootPane, KeyEvent.VK_EQUALS, KeyEvent.SHIFT_DOWN_MASK, "zoom-in-plus", canvas::zoomIn); bindMap(rootPane, KeyEvent.VK_ADD, 0, "zoom-in-keypad", canvas::zoomIn); diff --git a/core/src/main/java/com/volmit/iris/core/gui/VisionRenderController.java b/core/src/main/java/com/volmit/iris/core/gui/VisionRenderController.java index 0699331c2..f21a3f2f0 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/VisionRenderController.java +++ b/core/src/main/java/com/volmit/iris/core/gui/VisionRenderController.java @@ -20,10 +20,11 @@ package com.volmit.iris.core.gui; import com.volmit.iris.Iris; import com.volmit.iris.core.gui.components.IrisRenderer; +import com.volmit.iris.core.gui.components.MapLabels; +import com.volmit.iris.core.gui.components.MapTile; import com.volmit.iris.core.gui.components.RenderType; import java.awt.EventQueue; -import java.awt.image.BufferedImage; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; @@ -60,7 +61,7 @@ final class VisionRenderController implements AutoCloseable { private static final int MAXIMUM_VISIBLE_TILES = 32_768; private static final int PROBE_QUEUE_CAPACITY = 1; private static final int COARSER_FALLBACK_LEVELS = 6; - private static final long CACHE_BYTES = 128L * 1024L * 1024L; + private static final long CACHE_BYTES = 256L * 1024L * 1024L; private final Runnable listener; private final int workerCount; @@ -119,7 +120,7 @@ final class VisionRenderController implements AutoCloseable { List tiles = visibleTiles(spec, renderScale); Frame frame = new Frame(viewSequence.incrementAndGet(), spec, renderScale, tiles); for (VisibleTile tile : tiles) { - tile.setImage(cache.get(tile.key())); + tile.setTile(cachedTileServing(tile.key())); } currentFrame = frame; probeSequence.incrementAndGet(); @@ -133,17 +134,30 @@ final class VisionRenderController implements AutoCloseable { * A tile from another zoom level covering the given one, to draw while the real tile renders. */ Fallback fallback(Frame frame, VisibleTile tile) { + MapTile withoutHeights = cache.get(tile.key().withHeights(false)); + if (withoutHeights != null) { + return new Fallback(List.of(new FallbackPiece(withoutHeights, 0, 0, TILE_PIXELS, 0D, 0D, 1D))); + } Fallback coarser = coarserFallback(frame, tile); return coarser != null ? coarser : finerFallback(frame, tile); } + // A tile drawn with heights serves just as well where no heights are needed, so turning shading off redraws nothing. + private MapTile cachedTileServing(TileKey key) { + MapTile exact = cache.get(key); + if (exact != null || key.heights()) { + return exact; + } + return cache.get(key.withHeights(true)); + } + Progress progress(Frame frame) { if (frame == null) { return new Progress(0, 0, 0); } int ready = 0; for (VisibleTile tile : frame.tiles()) { - if (tile.image() != null) { + if (tile.tile() != null) { ready++; } } @@ -198,7 +212,7 @@ final class VisionRenderController implements AutoCloseable { for (long tileX = minimumX; tileX <= maximumX; tileX++) { double deltaX = tileX + 0.5D - centerTileX; double deltaZ = tileZ + 0.5D - centerTileZ; - TileKey key = new TileKey(spec.contentRevision(), spec.type(), renderScale, tileX, tileZ); + TileKey key = new TileKey(spec.contentRevision(), spec.type(), spec.heights(), renderScale, tileX, tileZ); tiles.add(new VisibleTile(key, deltaX * deltaX + deltaZ * deltaZ)); } } @@ -283,19 +297,20 @@ final class VisionRenderController implements AutoCloseable { TileKey key = job.key(); try { double tileSpan = TILE_PIXELS * key.blocksPerPixel(); - BufferedImage image = job.renderer().renderStudio( + MapTile tile = job.renderer().renderTile( key.tileX() * tileSpan, key.tileZ() * tileSpan, - tileSpan, + key.blocksPerPixel(), TILE_PIXELS, key.type(), + key.heights(), () -> !isWanted(key) ); - cache.put(key, image); + cache.put(key, tile); Frame frame = currentFrame; VisibleTile visible = frame == null ? null : frame.tile(key); if (visible != null) { - visible.setImage(image); + visible.setTile(tile); publish(); } } catch (CancellationException ignored) { @@ -322,7 +337,8 @@ final class VisionRenderController implements AutoCloseable { } long parentX = Math.floorDiv(key.tileX(), scale); long parentZ = Math.floorDiv(key.tileZ(), scale); - BufferedImage parent = cache.get(new TileKey(key.contentRevision(), key.type(), parentScale, parentX, parentZ)); + MapTile parent = cache.get(new TileKey(key.contentRevision(), key.type(), key.heights(), parentScale, parentX, + parentZ)); if (parent != null) { int sourceX = (int) (key.tileX() - parentX * scale) * sourceSize; int sourceZ = (int) (key.tileZ() - parentZ * scale) * sourceSize; @@ -341,7 +357,7 @@ final class VisionRenderController implements AutoCloseable { List pieces = new ArrayList<>(4); for (int childZ = 0; childZ < 2; childZ++) { for (int childX = 0; childX < 2; childX++) { - BufferedImage child = cache.get(new TileKey(key.contentRevision(), key.type(), childScale, + MapTile child = cache.get(new TileKey(key.contentRevision(), key.type(), key.heights(), childScale, key.tileX() * 2L + childX, key.tileZ() * 2L + childZ)); if (child != null) { pieces.add(new FallbackPiece(child, 0, 0, TILE_PIXELS, childX * 0.5D, childZ * 0.5D, 0.5D)); @@ -410,7 +426,8 @@ final class VisionRenderController implements AutoCloseable { double centerZ, double blocksPerPixel, int width, - int height + int height, + boolean heights ) { RenderSpec { Objects.requireNonNull(renderer, "renderer"); @@ -468,11 +485,33 @@ final class VisionRenderController implements AutoCloseable { return tilesByKey.get(key); } + /** + * The tile covering a world position together with the pixel of it there, or null when that tile is not drawn yet. + */ + TilePixel pixelAt(double worldX, double worldZ) { + double tileSpan = TILE_PIXELS * renderBlocksPerPixel; + long tileX = (long) Math.floor(worldX / tileSpan); + long tileZ = (long) Math.floor(worldZ / tileSpan); + VisibleTile visible = tilesByKey.get(new TileKey(spec.contentRevision(), spec.type(), spec.heights(), + renderBlocksPerPixel, tileX, tileZ)); + MapTile tile = visible == null ? null : visible.tile(); + if (tile == null) { + return null; + } + int pixelX = pixelWithin(worldX - tileX * tileSpan); + int pixelZ = pixelWithin(worldZ - tileZ * tileSpan); + return new TilePixel(tile, pixelX, pixelZ); + } + + private int pixelWithin(double offsetBlocks) { + return Math.max(0, Math.min(TILE_PIXELS - 1, (int) Math.floor(offsetBlocks / renderBlocksPerPixel))); + } + // Tiles are sorted centre first, and everything before the cursor is drawn or claimed, so each search resumes there. private VisibleTile nextMissing(Set inFlight, boolean advance) { for (int index = nextCandidate; index < tiles.size(); index++) { VisibleTile tile = tiles.get(index); - if (tile.image() == null && !inFlight.contains(tile.key())) { + if (tile.tile() == null && !inFlight.contains(tile.key())) { if (advance) { nextCandidate = index + 1; } @@ -489,7 +528,7 @@ final class VisionRenderController implements AutoCloseable { static final class VisibleTile { private final TileKey key; private final double distanceSquared; - private volatile BufferedImage image; + private volatile MapTile tile; private VisibleTile(TileKey key, double distanceSquared) { this.key = key; @@ -512,22 +551,39 @@ final class VisionRenderController implements AutoCloseable { return distanceSquared; } - BufferedImage image() { - return image; + MapTile tile() { + return tile; } - private void setImage(BufferedImage image) { - this.image = image; + private void setTile(MapTile tile) { + this.tile = tile; } } - record TileKey(long contentRevision, RenderType type, double blocksPerPixel, long tileX, long tileZ) { + /** + * @param heights whether the tile also carries terrain heights, for shading + */ + record TileKey(long contentRevision, RenderType type, boolean heights, double blocksPerPixel, long tileX, long tileZ) { TileKey { Objects.requireNonNull(type, "type"); if (!Double.isFinite(blocksPerPixel) || blocksPerPixel <= 0D) { throw new IllegalArgumentException("Vision cache scale must be finite and positive"); } } + + TileKey withHeights(boolean nextHeights) { + return new TileKey(contentRevision, type, nextHeights, blocksPerPixel, tileX, tileZ); + } + } + + record TilePixel(MapTile tile, int pixelX, int pixelZ) { + MapLabels.Label subject() { + return tile.labels().label(tile.subjectAt(pixelX, pixelZ)); + } + + MapLabels.Label region() { + return tile.labels().label(tile.regionAt(pixelX, pixelZ)); + } } /** @@ -536,7 +592,7 @@ final class VisionRenderController implements AutoCloseable { record Fallback(List pieces) { } - record FallbackPiece(BufferedImage image, int sourceX, int sourceZ, int sourceSize, double offsetX, double offsetZ, + record FallbackPiece(MapTile tile, int sourceX, int sourceZ, int sourceSize, double offsetX, double offsetZ, double size) { } @@ -562,18 +618,18 @@ final class VisionRenderController implements AutoCloseable { this.entries = new LinkedHashMap<>(128, 0.75F, true); } - synchronized BufferedImage get(TileKey key) { + synchronized MapTile get(TileKey key) { CacheEntry entry = entries.get(key); - return entry == null ? null : entry.image(); + return entry == null ? null : entry.tile(); } - synchronized void put(TileKey key, BufferedImage image) { - long imageBytes = (long) image.getWidth() * image.getHeight() * Integer.BYTES; - CacheEntry previous = entries.put(key, new CacheEntry(image, imageBytes)); + synchronized void put(TileKey key, MapTile tile) { + long tileBytes = tile.bytes(); + CacheEntry previous = entries.put(key, new CacheEntry(tile, tileBytes)); if (previous != null) { bytes -= previous.bytes(); } - bytes += imageBytes; + bytes += tileBytes; while (bytes > maximumBytes && !entries.isEmpty()) { Iterator> iterator = entries.entrySet().iterator(); Map.Entry eldest = iterator.next(); @@ -588,6 +644,6 @@ final class VisionRenderController implements AutoCloseable { } } - private record CacheEntry(BufferedImage image, long bytes) { + private record CacheEntry(MapTile tile, long bytes) { } } diff --git a/core/src/main/java/com/volmit/iris/core/gui/components/IrisRenderer.java b/core/src/main/java/com/volmit/iris/core/gui/components/IrisRenderer.java index ab8e7ba48..6749fed99 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/components/IrisRenderer.java +++ b/core/src/main/java/com/volmit/iris/core/gui/components/IrisRenderer.java @@ -18,7 +18,6 @@ package com.volmit.iris.core.gui.components; -import com.volmit.iris.core.gui.GuiHost; import com.volmit.iris.engine.IrisComplex; import com.volmit.iris.engine.framework.Engine; import com.volmit.iris.engine.object.IrisBiome; @@ -28,121 +27,87 @@ import com.volmit.iris.engine.object.IrisRegion; import com.volmit.iris.util.stream.ProceduralStream; import java.awt.Color; -import java.awt.image.BufferedImage; import java.util.Arrays; import java.util.IdentityHashMap; import java.util.List; import java.util.Objects; import java.util.concurrent.CancellationException; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.BooleanSupplier; +/** + * Draws map tiles for one generation of the pack: a hotload needs a new renderer, which also starts new labels. + */ public final class IrisRenderer { - private static final int BLUE = new Color(45, 91, 156).getRGB(); - private static final int YELLOW = new Color(211, 164, 67).getRGB(); - private static final int GREEN = new Color(78, 137, 83).getRGB(); - private static final int DEEP_WATER = new Color(20, 48, 92).getRGB(); - private static final int SHALLOW_WATER = new Color(50, 112, 154).getRGB(); - private static final int LOWLAND = new Color(78, 128, 76).getRGB(); - private static final int HIGHLAND = new Color(151, 139, 92).getRGB(); - private static final int ROCK = new Color(126, 119, 112).getRGB(); - private static final int SNOW = new Color(226, 230, 232).getRGB(); + private static final int BLUE = rgb(45, 91, 156); + private static final int YELLOW = rgb(211, 164, 67); + private static final int GREEN = rgb(78, 137, 83); + private static final int DEEP_WATER = rgb(20, 48, 92); + private static final int SHALLOW_WATER = rgb(50, 112, 154); + private static final int LOWLAND = rgb(78, 128, 76); + private static final int HIGHLAND = rgb(151, 139, 92); + private static final int ROCK = rgb(126, 119, 112); + private static final int SNOW = rgb(226, 230, 232); + private static final int UNKNOWN_SUBJECT = rgb(90, 92, 98); + private static final int LARGEST_SAMPLE_BLOCK_PIXELS = 16; + private static final double LARGEST_SAMPLE_BLOCK_BLOCKS = 64D; - private final Engine renderer; + private final Engine engine; + private final MapLabels labels = new MapLabels(); + private final ConcurrentHashMap labelColors = new ConcurrentHashMap<>(); - public IrisRenderer(Engine renderer) { - this.renderer = Objects.requireNonNull(renderer, "renderer"); + public IrisRenderer(Engine engine) { + this.engine = Objects.requireNonNull(engine, "engine"); } - public BufferedImage render(double sx, double sz, double size, int resolution, RenderType currentType) { - return render(sx, sz, size, resolution, currentType, () -> false); + public MapLabels labels() { + return labels; } - public BufferedImage render( - double sx, - double sz, - double size, - int resolution, - RenderType currentType, + /** + * Renders the square of {@code size} pixels whose top left pixel samples the given world position. + * + * @param withHeights also sample terrain heights for shading; the height view always has them + */ + public MapTile renderTile( + double startX, + double startZ, + double blocksPerPixel, + int size, + RenderType type, + boolean withHeights, BooleanSupplier cancelled ) { - return render(sx, sz, size, resolution, currentType, cancelled, false); - } - - public BufferedImage renderStudio( - double sx, - double sz, - double size, - int resolution, - RenderType currentType, - BooleanSupplier cancelled - ) { - return render(sx, sz, size, resolution, currentType, cancelled, true); - } - - private BufferedImage render( - double sx, - double sz, - double size, - int resolution, - RenderType currentType, - BooleanSupplier cancelled, - boolean studio - ) { - if (!Double.isFinite(sx) || !Double.isFinite(sz) || !Double.isFinite(size) || size <= 0D) { - throw new IllegalArgumentException("Vision render coordinates and size must be finite"); + if (!Double.isFinite(startX) || !Double.isFinite(startZ) || !Double.isFinite(blocksPerPixel) || blocksPerPixel <= 0D) { + throw new IllegalArgumentException("Map tile coordinates and scale must be finite"); } - if (resolution < 1) { - throw new IllegalArgumentException("Vision render resolution must be positive"); + if (size < 1) { + throw new IllegalArgumentException("Map tile size must be positive"); } - Objects.requireNonNull(currentType, "currentType"); + Objects.requireNonNull(type, "type"); Objects.requireNonNull(cancelled, "cancelled"); checkCancelled(cancelled); - int[] pixels = renderPixels(sx, sz, size, resolution, currentType, cancelled, studio); - return GuiHost.uncachedImage(pixels, resolution, resolution); + TileGrid grid = new TileGrid(size + 2, startX - blocksPerPixel, startZ - blocksPerPixel, blocksPerPixel, cancelled); + int[] identities = sampleIdentities(grid, type); + double[] heights = withHeights || type == RenderType.HEIGHT ? sampleHeights(grid) : null; + short[] subjects = new short[identities.length]; + short[] regions = new short[identities.length]; + for (int index = 0; index < identities.length; index++) { + subjects[index] = subjectOf(identities[index]); + regions[index] = regionOf(identities[index]); + } + int[] colors = tileColors(size, type, subjects, heights); + MapLabels.Kind subjectKind = type == RenderType.REGION ? MapLabels.Kind.REGION : MapLabels.Kind.BIOME; + return new MapTile(size, blocksPerPixel, labels, subjectKind, colors, subjects, regions, heights); } - private int[] renderPixels( - double sx, - double sz, - double size, - int resolution, - RenderType currentType, - BooleanSupplier cancelled, - boolean studio - ) { - int[] pixels = new int[resolution * resolution]; - double step = size / resolution; - if (studio && currentType == RenderType.HEIGHT) { - renderHeightAtlas(pixels, resolution, sx, sz, step, renderer, cancelled); - return pixels; - } - PixelShader shader = shader(currentType, studio); - if (studio && adaptiveStudioType(currentType)) { - renderAdaptiveAtlas(pixels, resolution, sx, sz, step, shader, cancelled); - return pixels; - } - int groupSize = sampleGroup(step, resolution); - - for (int groupZ = 0; groupZ < resolution; groupZ += groupSize) { - checkCancelled(cancelled); - int maximumZ = Math.min(resolution, groupZ + groupSize); - for (int groupX = 0; groupX < resolution; groupX += groupSize) { - checkCancelled(cancelled); - int maximumX = Math.min(resolution, groupX + groupSize); - for (int pixelZ = groupZ; pixelZ < maximumZ; pixelZ++) { - double z = sz + step * pixelZ; - int row = pixelZ * resolution; - for (int pixelX = groupX; pixelX < maximumX; pixelX++) { - checkCancelled(cancelled); - double x = sx + step * pixelX; - pixels[row + pixelX] = shader.color(x, z); - } - } - } - } - - return pixels; + /** + * The colour a biome or region has on the map in the given view, for legends. + */ + public int legendColor(MapLabels.Label label, RenderType type) { + RenderType colorType = type == RenderType.HEIGHT ? RenderType.BIOME : type; + return labelColor(label.id(), colorType); } public static int heightColor(double height, double maximumHeight, double fluidHeight) { @@ -164,151 +129,96 @@ public final class IrisRenderer { return blend(ROCK, SNOW, (land - 0.78D) / 0.22D); } - static int sampleGroup(double step, int resolution) { - double absoluteStep = Math.abs(step); - if (!Double.isFinite(absoluteStep) || absoluteStep <= 0D) { - return 1; - } - return Math.max(1, Math.min(resolution, (int) Math.floor(16D / absoluteStep))); + private int[] sampleIdentities(TileGrid grid, RenderType type) { + IdentitySampler sampler = new IdentitySampler(grid, identityShader(type)); + sampler.renderAll(sampleBlockPixels(grid.step())); + return sampler.identities; } - private PixelShader shader(RenderType currentType, boolean studio) { - IrisComplex complex = renderer.getComplex(); - return switch (currentType) { - case BIOME, DECORATOR_LOAD, OBJECT_LOAD, LAYER_LOAD -> biomeShader( - studio ? complex.getBaseBiomeStream() : complex.getTrueBiomeStream(), currentType); - case BIOME_LAND -> biomeShader(complex.getLandBiomeStream(), currentType); - case BIOME_SEA -> biomeShader(complex.getSeaBiomeStream(), currentType); - case REGION -> regionShader(complex, currentType); - case CAVE_LAND -> biomeShader(complex.getCaveBiomeStream(), currentType); - case HEIGHT -> heightShader(complex.getHeightStream()); - case CONTINENT -> studio - ? continentShader(complex.getBaseBiomeStream()) - : this::continentColor; - }; + private double[] sampleHeights(TileGrid grid) { + return new HeightLattice(grid, engine.getComplex().getHeightStream()).interpolatedHeights(); } - private static boolean adaptiveStudioType(RenderType type) { - return switch (type) { - case BIOME, DECORATOR_LOAD, OBJECT_LOAD, LAYER_LOAD, BIOME_LAND, BIOME_SEA, REGION, CAVE_LAND, - CONTINENT -> true; - case HEIGHT -> false; - }; + // Large uniform squares are filled after a few samples; squares stay small enough to not skip tiny biomes. + private static int sampleBlockPixels(double blocksPerPixel) { + int largestPixels = (int) Math.floor(LARGEST_SAMPLE_BLOCK_BLOCKS / blocksPerPixel); + return Integer.highestOneBit(Math.max(1, Math.min(LARGEST_SAMPLE_BLOCK_PIXELS, largestPixels))); } - private static void renderAdaptiveAtlas( - int[] pixels, - int resolution, - double startX, - double startZ, - double step, - PixelShader shader, - BooleanSupplier cancelled - ) { - int maximumPixels = Math.max(1, Math.min(16, (int) Math.floor(64D / step))); - int blockPixels = Integer.highestOneBit(maximumPixels); - AdaptiveSampler sampler = new AdaptiveSampler(pixels, resolution, startX, startZ, step, shader, cancelled); - for (int pixelZ = 0; pixelZ < resolution; pixelZ += blockPixels) { - int height = Math.min(blockPixels, resolution - pixelZ); - for (int pixelX = 0; pixelX < resolution; pixelX += blockPixels) { - sampler.render(pixelX, pixelZ, Math.min(blockPixels, resolution - pixelX), height); - } - } - } - - private static void renderHeightAtlas( - int[] pixels, - int resolution, - double startX, - double startZ, - double step, - Engine engine, - BooleanSupplier cancelled - ) { + private IdentityShader identityShader(RenderType type) { IrisComplex complex = engine.getComplex(); + ProceduralStream regionStream = complex.getRegionStream(); + LabelLookup lookup = new LabelLookup(labels); + if (type == RenderType.REGION) { + return (double x, double z) -> { + short region = lookup.idOf(regionStream.get(x, z)); + return identity(region, region); + }; + } + ProceduralStream biomeStream = subjectBiomeStream(complex, type); + return (double x, double z) -> identity(lookup.idOf(biomeStream.get(x, z)), lookup.idOf(regionStream.get(x, z))); + } + + private static ProceduralStream subjectBiomeStream(IrisComplex complex, RenderType type) { + return switch (type) { + case BIOME_LAND -> complex.getLandBiomeStream(); + case BIOME_SEA -> complex.getSeaBiomeStream(); + case CAVE_LAND -> complex.getCaveBiomeStream(); + case BIOME, DECORATOR_LOAD, OBJECT_LOAD, LAYER_LOAD, HEIGHT, CONTINENT, REGION -> complex.getBaseBiomeStream(); + }; + } + + private int[] tileColors(int size, RenderType type, short[] subjects, double[] heights) { + int[] colors = new int[size * size]; + double maximumHeight = engine.getHeight(); + double fluidHeight = fluidHeight(); + short previousSubject = MapLabels.NONE; + int previousColor = UNKNOWN_SUBJECT; + for (int pixelZ = 0; pixelZ < size; pixelZ++) { + for (int pixelX = 0; pixelX < size; pixelX++) { + int gridIndex = (pixelZ + 1) * (size + 2) + pixelX + 1; + if (type == RenderType.HEIGHT) { + colors[pixelZ * size + pixelX] = heightColor(heights[gridIndex], maximumHeight, fluidHeight); + continue; + } + short subject = subjects[gridIndex]; + if (subject != previousSubject) { + previousSubject = subject; + previousColor = labelColor(subject, type); + } + colors[pixelZ * size + pixelX] = previousColor; + } + } + return colors; + } + + private double fluidHeight() { IrisDimension dimension = engine.getDimension(); - double fluidHeight = dimension == null ? 0D : dimension.getFluidHeight(); - int maximumPixels = Math.max(1, Math.min(16, (int) Math.floor(64D / step))); - int blockPixels = Integer.highestOneBit(maximumPixels); - HeightSampler sampler = new HeightSampler( - pixels, - resolution, - startX, - startZ, - step, - complex.getHeightStream(), - engine.getHeight(), - fluidHeight, - cancelled - ); - for (int pixelZ = 0; pixelZ < resolution; pixelZ += blockPixels) { - int height = Math.min(blockPixels, resolution - pixelZ); - for (int pixelX = 0; pixelX < resolution; pixelX += blockPixels) { - sampler.render(pixelX, pixelZ, Math.min(blockPixels, resolution - pixelX), height); - } + return dimension == null ? 0D : dimension.getFluidHeight(); + } + + private int labelColor(short id, RenderType type) { + MapLabels.Label label = labels.label(id); + if (label == null) { + return UNKNOWN_SUBJECT; } + return labelColors.computeIfAbsent(new LabelColorKey(type, id), key -> subjectColor(label.subject(), type)); } - private PixelShader biomeShader(ProceduralStream stream, RenderType currentType) { - IdentityHashMap colors = new IdentityHashMap<>(); - return (double x, double z) -> { - IrisBiome biome = stream.get(x, z); - Integer color = colors.get(biome); - if (color == null) { - color = biome.getColor(renderer, currentType).getRGB(); - colors.put(biome, color); - } - return color; - }; - } - - private PixelShader regionShader(IrisComplex complex, RenderType currentType) { - ProceduralStream stream = complex.getRegionStream(); - IdentityHashMap colors = new IdentityHashMap<>(); - return (double x, double z) -> { - IrisRegion region = stream.get(x, z); - Integer color = colors.get(region); - if (color == null) { - color = region.getColor(complex, currentType).getRGB(); - colors.put(region, color); - } - return color; - }; - } - - private PixelShader heightShader(ProceduralStream stream) { - double maximumHeight = renderer.getHeight(); - IrisDimension dimension = renderer.getDimension(); - double fluidHeight = dimension == null ? 0D : dimension.getFluidHeight(); - return (double x, double z) -> heightColor(stream.getDouble(x, z), maximumHeight, fluidHeight); - } - - private int continentColor(double x, double z) { - IrisBiome biome = renderer.getBiome( - (int) Math.round(x), - renderer.getMaxHeight() - 1, - (int) Math.round(z) - ); - return continentColor(biome); - } - - private PixelShader continentShader(ProceduralStream stream) { - IdentityHashMap colors = new IdentityHashMap<>(); - return (double x, double z) -> { - IrisBiome biome = stream.get(x, z); - Integer color = colors.get(biome); - if (color == null) { - color = continentColor(biome); - colors.put(biome, color); - } - return color; - }; - } - - private int continentColor(IrisBiome biome) { - if (biome == null) { - return GREEN; + private int subjectColor(Object subject, RenderType type) { + if (type == RenderType.CONTINENT && subject instanceof IrisBiome biome) { + return continentColor(biome); } + if (subject instanceof IrisRegion region) { + return region.getColor(engine.getComplex(), type).getRGB() & 0xFFFFFF; + } + if (subject instanceof IrisBiome biome) { + return biome.getColor(engine, type).getRGB() & 0xFFFFFF; + } + return UNKNOWN_SUBJECT; + } + + private static int continentColor(IrisBiome biome) { List generators = biome.getGenerators(); if (generators.isEmpty()) { return GREEN; @@ -323,6 +233,22 @@ public final class IrisRenderer { return GREEN; } + private static int identity(short subject, short region) { + return (subject << 16) | (region & 0xFFFF); + } + + private static short subjectOf(int identity) { + return (short) (identity >> 16); + } + + private static short regionOf(int identity) { + return (short) identity; + } + + private static int rgb(int red, int green, int blue) { + return new Color(red, green, blue).getRGB() & 0xFFFFFF; + } + private static int blend(int first, int second, double progress) { double bounded = clamp(progress, 0D, 1D); int red = (int) Math.round(((first >> 16) & 0xFF) * (1D - bounded) + ((second >> 16) & 0xFF) * bounded); @@ -337,262 +263,208 @@ public final class IrisRenderer { private static void checkCancelled(BooleanSupplier cancelled) { if (cancelled.getAsBoolean() || Thread.currentThread().isInterrupted()) { - throw new CancellationException("Vision render cancelled"); + throw new CancellationException("Map tile render cancelled"); } } @FunctionalInterface - private interface PixelShader { - int color(double x, double z); + private interface IdentityShader { + int identity(double x, double z); } - private static final class AdaptiveSampler { - private final int[] pixels; - private final boolean[] sampled; - private final int resolution; - private final double startX; - private final double startZ; - private final double step; - private final PixelShader shader; - private final BooleanSupplier cancelled; + private record LabelColorKey(RenderType type, short id) { + } - private AdaptiveSampler( - int[] pixels, - int resolution, - double startX, - double startZ, - double step, - PixelShader shader, - BooleanSupplier cancelled - ) { - this.pixels = pixels; - this.sampled = new boolean[pixels.length]; - this.resolution = resolution; - this.startX = startX; - this.startZ = startZ; - this.step = step; - this.shader = shader; - this.cancelled = cancelled; + /** + * The square of world positions one tile samples, including the one pixel ring borrowed from its neighbours. + */ + private record TileGrid(int size, double startX, double startZ, double step, BooleanSupplier cancelled) { + double worldX(int gridX) { + return startX + gridX * step; } - private void render(int pixelX, int pixelZ, int width, int height) { - checkCancelled(cancelled); + double worldZ(int gridZ) { + return startZ + gridZ * step; + } + } + + /** + * Labels the biomes and regions one tile meets, asking the shared labels only for ones this tile has not seen yet. + */ + private static final class LabelLookup { + private final MapLabels labels; + private final IdentityHashMap seen = new IdentityHashMap<>(); + + private LabelLookup(MapLabels labels) { + this.labels = labels; + } + + short idOf(IrisBiome biome) { + if (biome == null) { + return MapLabels.NONE; + } + return seen.computeIfAbsent(biome, subject -> labels.idOf(biome)); + } + + short idOf(IrisRegion region) { + if (region == null) { + return MapLabels.NONE; + } + return seen.computeIfAbsent(region, subject -> labels.idOf(region)); + } + } + + /** + * Samples the biome and region of every grid cell in squares, filling a square without sampling its inside when + * its corners and centre agree, and splitting it into quarters otherwise. + */ + private static final class IdentitySampler { + private final TileGrid grid; + private final IdentityShader shader; + private final int[] identities; + private final boolean[] sampled; + + private IdentitySampler(TileGrid grid, IdentityShader shader) { + this.grid = grid; + this.shader = shader; + this.identities = new int[grid.size() * grid.size()]; + this.sampled = new boolean[identities.length]; + } + + void renderAll(int blockPixels) { + int size = grid.size(); + for (int gridZ = 0; gridZ < size; gridZ += blockPixels) { + int height = Math.min(blockPixels, size - gridZ); + for (int gridX = 0; gridX < size; gridX += blockPixels) { + render(gridX, gridZ, Math.min(blockPixels, size - gridX), height); + } + } + } + + private void render(int gridX, int gridZ, int width, int height) { + checkCancelled(grid.cancelled()); if (width == 1 && height == 1) { - sample(pixelX, pixelZ); + sample(gridX, gridZ); return; } - int maximumX = pixelX + width - 1; - int maximumZ = pixelZ + height - 1; - int centerX = pixelX + width / 2; - int centerZ = pixelZ + height / 2; - int color = sample(pixelX, pixelZ); - if (sample(maximumX, pixelZ) == color - && sample(pixelX, maximumZ) == color - && sample(maximumX, maximumZ) == color - && sample(centerX, centerZ) == color) { - fill(pixelX, pixelZ, width, height, color); + if (fillIfUniform(gridX, gridZ, width, height)) { return; } int leftWidth = Math.max(1, width / 2); int rightWidth = width - leftWidth; int topHeight = Math.max(1, height / 2); int bottomHeight = height - topHeight; - render(pixelX, pixelZ, leftWidth, topHeight); + render(gridX, gridZ, leftWidth, topHeight); if (rightWidth > 0) { - render(pixelX + leftWidth, pixelZ, rightWidth, topHeight); + render(gridX + leftWidth, gridZ, rightWidth, topHeight); } if (bottomHeight > 0) { - render(pixelX, pixelZ + topHeight, leftWidth, bottomHeight); + render(gridX, gridZ + topHeight, leftWidth, bottomHeight); if (rightWidth > 0) { - render(pixelX + leftWidth, pixelZ + topHeight, rightWidth, bottomHeight); + render(gridX + leftWidth, gridZ + topHeight, rightWidth, bottomHeight); } } } - private int sample(int pixelX, int pixelZ) { - int index = pixelZ * resolution + pixelX; - if (!sampled[index]) { - pixels[index] = shader.color(startX + pixelX * step, startZ + pixelZ * step); - sampled[index] = true; + private boolean fillIfUniform(int gridX, int gridZ, int width, int height) { + int maximumX = gridX + width - 1; + int maximumZ = gridZ + height - 1; + int identity = sample(gridX, gridZ); + boolean uniform = sample(maximumX, gridZ) == identity + && sample(gridX, maximumZ) == identity + && sample(maximumX, maximumZ) == identity + && sample(gridX + width / 2, gridZ + height / 2) == identity; + if (!uniform) { + return false; } - return pixels[index]; - } - - private void fill(int pixelX, int pixelZ, int width, int height, int color) { - for (int row = pixelZ; row < pixelZ + height; row++) { - int start = row * resolution + pixelX; - Arrays.fill(pixels, start, start + width, color); + for (int row = gridZ; row <= maximumZ; row++) { + int start = row * grid.size() + gridX; + Arrays.fill(identities, start, start + width, identity); Arrays.fill(sampled, start, start + width, true); } + return true; + } + + private int sample(int gridX, int gridZ) { + int index = gridZ * grid.size() + gridX; + if (!sampled[index]) { + identities[index] = shader.identity(grid.worldX(gridX), grid.worldZ(gridZ)); + sampled[index] = true; + } + return identities[index]; } } - private static final class HeightSampler { - private static final double MAXIMUM_INTERPOLATION_ERROR = 1.5D; + /** + * Terrain heights from samples on a coarse lattice, smoothly interpolated in between. Iris heights are costly to + * sample, and shading turns the steps of cheaper shortcuts into visible creases, so a smooth curve through fewer + * exact samples looks better and draws an order of magnitude faster than sampling every pixel. The lattice lines + * up across tiles, so slopes continue seamlessly from one tile into the next. + */ + private static final class HeightLattice { + private static final int LATTICE_PIXELS = 4; + private static final int LATTICE_MARGIN = 2; - private final int[] pixels; - private final double[] heights; - private final boolean[] sampled; - private final int resolution; - private final double startX; - private final double startZ; - private final double step; + private final TileGrid grid; private final ProceduralStream stream; - private final double maximumHeight; - private final double fluidHeight; - private final BooleanSupplier cancelled; + private final int pointsPerSide; + private final double[] points; - private HeightSampler( - int[] pixels, - int resolution, - double startX, - double startZ, - double step, - ProceduralStream stream, - double maximumHeight, - double fluidHeight, - BooleanSupplier cancelled - ) { - this.pixels = pixels; - this.heights = new double[pixels.length]; - this.sampled = new boolean[pixels.length]; - this.resolution = resolution; - this.startX = startX; - this.startZ = startZ; - this.step = step; + private HeightLattice(TileGrid grid, ProceduralStream stream) { + this.grid = grid; this.stream = stream; - this.maximumHeight = maximumHeight; - this.fluidHeight = fluidHeight; - this.cancelled = cancelled; + this.pointsPerSide = (grid.size() - 2) / LATTICE_PIXELS + LATTICE_MARGIN * 2 + 1; + this.points = new double[pointsPerSide * pointsPerSide]; } - private void render(int pixelX, int pixelZ, int width, int height) { - checkCancelled(cancelled); - if (width == 1 && height == 1) { - pixels[pixelZ * resolution + pixelX] = heightColor( - sample(pixelX, pixelZ), maximumHeight, fluidHeight); - return; + double[] interpolatedHeights() { + samplePoints(); + int size = grid.size(); + double[] heights = new double[size * size]; + for (int gridZ = 0; gridZ < size; gridZ++) { + for (int gridX = 0; gridX < size; gridX++) { + heights[gridZ * size + gridX] = heightAt(gridX - 1, gridZ - 1); + } } - int maximumX = pixelX + width - 1; - int maximumZ = pixelZ + height - 1; - int centerX = pixelX + width / 2; - int centerZ = pixelZ + height / 2; - double topLeft = sample(pixelX, pixelZ); - double topRight = sample(maximumX, pixelZ); - double bottomLeft = sample(pixelX, maximumZ); - double bottomRight = sample(maximumX, maximumZ); - if (matchesPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight, - centerX, pixelZ) - && matchesPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight, - pixelX, centerZ) - && matchesPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight, - maximumX, centerZ) - && matchesPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight, - centerX, maximumZ) - && matchesPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight, - centerX, centerZ)) { - fillPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight); - return; - } - int leftWidth = Math.max(1, width / 2); - int rightWidth = width - leftWidth; - int topHeight = Math.max(1, height / 2); - int bottomHeight = height - topHeight; - render(pixelX, pixelZ, leftWidth, topHeight); - if (rightWidth > 0) { - render(pixelX + leftWidth, pixelZ, rightWidth, topHeight); - } - if (bottomHeight > 0) { - render(pixelX, pixelZ + topHeight, leftWidth, bottomHeight); - if (rightWidth > 0) { - render(pixelX + leftWidth, pixelZ + topHeight, rightWidth, bottomHeight); + return heights; + } + + // Lattice point 0 sits on the tile's first pixel; the margin covers the border ring and the curve's reach. + private void samplePoints() { + double latticeStep = grid.step() * LATTICE_PIXELS; + double firstX = grid.worldX(1) - LATTICE_MARGIN * latticeStep; + double firstZ = grid.worldZ(1) - LATTICE_MARGIN * latticeStep; + for (int pointZ = 0; pointZ < pointsPerSide; pointZ++) { + checkCancelled(grid.cancelled()); + for (int pointX = 0; pointX < pointsPerSide; pointX++) { + points[pointZ * pointsPerSide + pointX] = stream.getDouble(firstX + pointX * latticeStep, + firstZ + pointZ * latticeStep); } } } - private boolean matchesPlane( - int pixelX, - int pixelZ, - int width, - int height, - double topLeft, - double topRight, - double bottomLeft, - double bottomRight, - int sampleX, - int sampleZ - ) { - double predicted = interpolate( - pixelX, - pixelZ, - width, - height, - topLeft, - topRight, - bottomLeft, - bottomRight, - sampleX, - sampleZ - ); - return Math.abs(sample(sampleX, sampleZ) - predicted) <= MAXIMUM_INTERPOLATION_ERROR; - } - - private void fillPlane( - int pixelX, - int pixelZ, - int width, - int height, - double topLeft, - double topRight, - double bottomLeft, - double bottomRight - ) { - for (int row = pixelZ; row < pixelZ + height; row++) { - int offset = row * resolution; - for (int column = pixelX; column < pixelX + width; column++) { - double value = interpolate( - pixelX, - pixelZ, - width, - height, - topLeft, - topRight, - bottomLeft, - bottomRight, - column, - row - ); - pixels[offset + column] = heightColor(value, maximumHeight, fluidHeight); - } + private double heightAt(int pixelX, int pixelZ) { + int cellX = Math.floorDiv(pixelX, LATTICE_PIXELS); + int cellZ = Math.floorDiv(pixelZ, LATTICE_PIXELS); + double fractionX = (pixelX - cellX * LATTICE_PIXELS) / (double) LATTICE_PIXELS; + double fractionZ = (pixelZ - cellZ * LATTICE_PIXELS) / (double) LATTICE_PIXELS; + int baseX = cellX + LATTICE_MARGIN; + int baseZ = cellZ + LATTICE_MARGIN; + double[] rows = new double[4]; + for (int row = 0; row < 4; row++) { + int offset = (baseZ - 1 + row) * pointsPerSide + baseX - 1; + rows[row] = catmullRom(points[offset], points[offset + 1], points[offset + 2], points[offset + 3], fractionX); } + return catmullRom(rows[0], rows[1], rows[2], rows[3], fractionZ); } - private double sample(int pixelX, int pixelZ) { - int index = pixelZ * resolution + pixelX; - if (!sampled[index]) { - heights[index] = stream.getDouble(startX + pixelX * step, startZ + pixelZ * step); - sampled[index] = true; - } - return heights[index]; - } - - private static double interpolate( - int pixelX, - int pixelZ, - int width, - int height, - double topLeft, - double topRight, - double bottomLeft, - double bottomRight, - int sampleX, - int sampleZ - ) { - double x = width <= 1 ? 0D : (sampleX - pixelX) / (double) (width - 1); - double z = height <= 1 ? 0D : (sampleZ - pixelZ) / (double) (height - 1); - double top = topLeft + (topRight - topLeft) * x; - double bottom = bottomLeft + (bottomRight - bottomLeft) * x; - return top + (bottom - top) * z; + private static double catmullRom(double before, double start, double end, double after, double fraction) { + double squared = fraction * fraction; + double cubed = squared * fraction; + return 0.5D * (2D * start + + (end - before) * fraction + + (2D * before - 5D * start + 4D * end - after) * squared + + (3D * start - before - 3D * end + after) * cubed); } } } diff --git a/core/src/main/java/com/volmit/iris/core/gui/components/MapLabels.java b/core/src/main/java/com/volmit/iris/core/gui/components/MapLabels.java new file mode 100644 index 000000000..ed3c60099 --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/components/MapLabels.java @@ -0,0 +1,80 @@ +/* + * 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.components; + +import com.volmit.iris.engine.object.IrisBiome; +import com.volmit.iris.engine.object.IrisRegion; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; + +/** + * Numbers every biome and region the map has drawn, so a tile can remember what each of its pixels shows in two bytes. + * One set of labels belongs to one generation of the pack: a hotload starts a new one. + */ +public final class MapLabels { + public static final short NONE = -1; + + private final IdentityHashMap idsBySubject = new IdentityHashMap<>(); + private final Map idsByKey = new HashMap<>(); + private final List