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