From 48bfa7d15bfb1e2c217c849a057f53c1aebb1a79 Mon Sep 17 00:00:00 2001 From: piexel Date: Sun, 25 Oct 2026 19:22:02 +0100 Subject: [PATCH 1/8] Call the cave biome view Cave land again - The map's cave view shows the 2D cave biome stream, not the biomes caves get per block, so it goes back to v4's name Model used: Claude Opus 5.5 --- core/src/main/java/art/arcane/iris/studio/map/MapView.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/java/art/arcane/iris/studio/map/MapView.java b/core/src/main/java/art/arcane/iris/studio/map/MapView.java index f897c5af2..81068a5c4 100644 --- a/core/src/main/java/art/arcane/iris/studio/map/MapView.java +++ b/core/src/main/java/art/arcane/iris/studio/map/MapView.java @@ -37,7 +37,7 @@ enum MapView { Group.BIOME_STREAMS, TileType.BIOME_LAND, KeyEvent.VK_6), SEA_BIOMES("Sea biomes", "The sea biome each spot would get, also where the land is", Group.BIOME_STREAMS, TileType.BIOME_SEA, KeyEvent.VK_7), - CAVE_BIOMES("Cave biomes", "The cave biome under each spot", Group.BIOME_STREAMS, TileType.CAVE_LAND, KeyEvent.VK_8), + CAVE_BIOMES("Cave land","The cave biome under each spot", Group.BIOME_STREAMS, TileType.CAVE_LAND, KeyEvent.VK_8), PALETTES("Palettes", "The blocks each biome's palette layers place, with their textures", Group.BLOCKS, null, KeyEvent.VK_P), OBJECTS("Objects", "Every object in the pack in 3D, with the blocks it is made of", From 5204d94ebe57e41e2532ab17d7a4f2353faf1406 Mon Sep 17 00:00:00 2001 From: piexel Date: Sun, 25 Oct 2026 19:22:02 +0100 Subject: [PATCH 2/8] Draw small decorations from the front in palette previews - Sea pickles, candles, lanterns, turtle eggs and potted plants show their shape instead of their whole unfolded texture - Flat decorations like pressure plates, rails and lily pads keep their top view Model used: Claude Opus 5.5 --- .../arcane/iris/studio/map/BlockTextures.java | 33 ++- .../iris/studio/map/ModelFrontView.java | 221 ++++++++++++++++++ .../iris/studio/map/ModelFrontViewTest.java | 92 ++++++++ 3 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 core/src/main/java/art/arcane/iris/studio/map/ModelFrontView.java create mode 100644 core/src/test/java/art/arcane/iris/studio/map/ModelFrontViewTest.java diff --git a/core/src/main/java/art/arcane/iris/studio/map/BlockTextures.java b/core/src/main/java/art/arcane/iris/studio/map/BlockTextures.java index e1827820a..96f09f203 100644 --- a/core/src/main/java/art/arcane/iris/studio/map/BlockTextures.java +++ b/core/src/main/java/art/arcane/iris/studio/map/BlockTextures.java @@ -51,6 +51,7 @@ final class BlockTextures { private final Map> resolvedTextures = new ConcurrentHashMap<>(); private final Map> resolvedModels = new ConcurrentHashMap<>(); private final Map> faceTextures = new ConcurrentHashMap<>(); + private final Map> icons = new ConcurrentHashMap<>(); private final BlockModelBuilder modelBuilder = new BlockModelBuilder(this); private BlockTextures(ZipFile clientJar, MinecraftBiomeColors biomeColors) { @@ -89,10 +90,40 @@ final class BlockTextures { /** * Like {@link #topTexture}, but plants keep their sprite, so single decorations on the ground stay recognisable. + * Small shapes standing on their spot, like sea pickles and candles, are drawn from the front for the same reason. * Two block tall plants give their bottom half, the part standing on the ground. */ Optional iconTexture(NativeBlockState block) { - return resolve(withHalf(block, BlockHalf.LOWER).orElse(block)).map(ResolvedTexture::texture); + NativeBlockState standing = withHalf(block, BlockHalf.LOWER).orElse(block); + return icons.computeIfAbsent(standing.key(), + state -> frontViewIcon(state).or(() -> resolve(standing).map(ResolvedTexture::texture))); + } + + // A small shape keeps all its parts side by side on one texture, so its top face's whole texture shows them all. + private Optional frontViewIcon(String blockState) { + try { + BlockStateId state = BlockStateId.parse(blockState); + JsonObject variant = variantOf(state); + if (variant == null || !variant.has("model")) { + return Optional.empty(); + } + ModelDefinition model = readModel(variant.get("model").getAsString()); + if (model.elements() == null || model.sideFace("up") == null || ModelFrontView.fillsMostOfItsSpotFromAbove(model.elements())) { + return Optional.empty(); + } + ModelFrontView front = ModelFrontView.draw(model.elements(), model.textures(), + texture -> readImage(assetPath(texture, "textures", ".png"))); + if (front == null) { + return Optional.empty(); + } + return Optional.of(BlockTexture.fromImage( + front.image(), + MinecraftBlockTints.fixedTint(state.name(), state.properties(), front.tintIndex()), + MinecraftBlockTints.biomeTint(state.name(), front.tintIndex()).orElse(null) + )); + } catch (IOException | RuntimeException error) { + return Optional.empty(); + } } /** diff --git a/core/src/main/java/art/arcane/iris/studio/map/ModelFrontView.java b/core/src/main/java/art/arcane/iris/studio/map/ModelFrontView.java new file mode 100644 index 000000000..a984d7980 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/ModelFrontView.java @@ -0,0 +1,221 @@ +/* + * 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 art.arcane.iris.studio.map; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * A block model drawn flat as seen from the south, each box's south face with its own part of the texture. Small + * shapes like sea pickles and candles keep all their parts on one texture, which only makes sense mapped onto the + * boxes. Tilted boxes are drawn untilted, which is close for the thin crossed planes such shapes use. + */ +final class ModelFrontView { + private static final double MODEL_UNITS = 16D; + private static final String FRONT = "south"; + private static final String TOP = "up"; + private static final double MOSTLY_COVERED_SHARE = 0.5D; + private static final int X = 0; + private static final int Y = 1; + private static final int Z = 2; + + /** + * Reads a texture's image by its sprite name. + */ + @FunctionalInterface + interface TextureImages { + BufferedImage read(String texture) throws IOException; + } + + private final BufferedImage image; + private final int tintIndex; + + private ModelFrontView(BufferedImage image, int tintIndex) { + this.image = image; + this.tintIndex = tintIndex; + } + + /** + * @return the front view, or null when the model has no front faces with a texture + */ + static ModelFrontView draw(JsonArray elements, Map textures, TextureImages images) throws IOException { + List faces = frontFacesNearestLast(elements, textures); + if (faces.isEmpty()) { + return null; + } + Map loaded = new HashMap<>(); + for (FrontFace face : faces) { + BufferedImage texture = images.read(face.texture()); + if (texture != null) { + loaded.put(face.texture(), texture); + } + } + if (loaded.isEmpty()) { + return null; + } + int size = loaded.values().iterator().next().getWidth(); + BufferedImage front = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + int tintIndex = BlockTextures.NO_TINT_INDEX; + for (FrontFace face : faces) { + BufferedImage texture = loaded.get(face.texture()); + if (texture != null) { + paint(face, texture, front); + tintIndex = tintIndex == BlockTextures.NO_TINT_INDEX ? face.tintIndex() : tintIndex; + } + } + return hasVisiblePixel(front) ? new ModelFrontView(front, tintIndex) : null; + } + + /** + * Whether the model's top faces cover most of its spot, like a slab, a carpet or a pressure plate does, rather than + * being a small thing standing on it. + */ + static boolean fillsMostOfItsSpotFromAbove(JsonArray elements) { + int size = (int) MODEL_UNITS; + return topCoveredPixels(elements, size) >= size * size * MOSTLY_COVERED_SHARE; + } + + private static int topCoveredPixels(JsonArray elements, int size) { + boolean[] covered = new boolean[size * size]; + for (JsonElement element : elements) { + JsonObject box = element.getAsJsonObject(); + JsonObject faces = box.getAsJsonObject("faces"); + if (faces == null || !faces.has(TOP)) { + continue; + } + JsonArray from = box.getAsJsonArray("from"); + JsonArray to = box.getAsJsonArray("to"); + fill(covered, size, pixel(from.get(X), size), pixel(to.get(X), size), pixel(from.get(Z), size), pixel(to.get(Z), size)); + } + int count = 0; + for (boolean pixelCovered : covered) { + count += pixelCovered ? 1 : 0; + } + return count; + } + + BufferedImage image() { + return image; + } + + int tintIndex() { + return tintIndex; + } + + // Faces further south are nearer the viewer, so they are painted over the ones behind them. + private static List frontFacesNearestLast(JsonArray elements, Map textures) { + List faces = new ArrayList<>(); + for (JsonElement element : elements) { + JsonObject box = element.getAsJsonObject(); + JsonObject boxFaces = box.getAsJsonObject("faces"); + if (boxFaces == null || !boxFaces.has(FRONT)) { + continue; + } + JsonObject face = boxFaces.getAsJsonObject(FRONT); + String texture = BlockTextures.dereference(face.get("texture").getAsString(), textures); + if (texture != null) { + faces.add(FrontFace.of(box, face, texture)); + } + } + faces.sort(Comparator.comparingDouble(FrontFace::depth)); + return faces; + } + + // Each pixel samples the texture at its centre, along the face's UVs, which run backwards on mirrored faces. + private static void paint(FrontFace face, BufferedImage texture, BufferedImage front) { + int size = front.getWidth(); + double scale = size / MODEL_UNITS; + double left = face.left() * scale; + double right = face.right() * scale; + double top = face.top() * scale; + double bottom = face.bottom() * scale; + int textureSize = texture.getWidth(); + for (int y = (int) Math.round(top); y < Math.round(bottom); y++) { + double alongV = (y + 0.5D - top) / (bottom - top); + int textureY = texel(face.v1() + alongV * (face.v2() - face.v1()), textureSize); + for (int x = (int) Math.round(left); x < Math.round(right); x++) { + double alongU = (x + 0.5D - left) / (right - left); + int textureX = texel(face.u1() + alongU * (face.u2() - face.u1()), textureSize); + int argb = texture.getRGB(textureX, textureY); + if (argb >>> 24 != 0 && x >= 0 && y >= 0 && x < size && y < size) { + front.setRGB(x, y, argb); + } + } + } + } + + // Animated textures stack their frames, so the first frame is as tall as the texture is wide. + private static int texel(double modelUnits, int textureSize) { + return Math.clamp((int) Math.floor(modelUnits / MODEL_UNITS * textureSize), 0, textureSize - 1); + } + + private static boolean hasVisiblePixel(BufferedImage front) { + for (int y = 0; y < front.getHeight(); y++) { + for (int x = 0; x < front.getWidth(); x++) { + if (front.getRGB(x, y) >>> 24 != 0) { + return true; + } + } + } + return false; + } + + private static int pixel(JsonElement modelUnits, int size) { + return (int) Math.round(modelUnits.getAsDouble() / MODEL_UNITS * size); + } + + private static void fill(boolean[] covered, int size, int fromX, int toX, int fromZ, int toZ) { + for (int z = Math.max(0, fromZ); z < Math.min(size, toZ); z++) { + for (int x = Math.max(0, fromX); x < Math.min(size, toX); x++) { + covered[z * size + x] = true; + } + } + } + + /** + * A south face in model units, with the image's y running down from the model's top. + */ + private record FrontFace(String texture, int tintIndex, double left, double right, double top, double bottom, double depth, + double u1, double v1, double u2, double v2) { + static FrontFace of(JsonObject box, JsonObject face, String texture) { + JsonArray from = box.getAsJsonArray("from"); + JsonArray to = box.getAsJsonArray("to"); + double left = from.get(X).getAsDouble(); + double right = to.get(X).getAsDouble(); + double top = MODEL_UNITS - to.get(Y).getAsDouble(); + double bottom = MODEL_UNITS - from.get(Y).getAsDouble(); + double[] uv = face.has("uv") ? uvOf(face.getAsJsonArray("uv")) : new double[]{left, top, right, bottom}; + return new FrontFace(texture, BlockTextures.tintIndexOf(face), left, right, top, bottom, to.get(Z).getAsDouble(), + uv[0], uv[1], uv[2], uv[3]); + } + + private static double[] uvOf(JsonArray uv) { + return new double[]{uv.get(0).getAsDouble(), uv.get(1).getAsDouble(), uv.get(2).getAsDouble(), uv.get(3).getAsDouble()}; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/studio/map/ModelFrontViewTest.java b/core/src/test/java/art/arcane/iris/studio/map/ModelFrontViewTest.java new file mode 100644 index 000000000..bfb9a367a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/studio/map/ModelFrontViewTest.java @@ -0,0 +1,92 @@ +package art.arcane.iris.studio.map; + +import com.google.gson.JsonArray; +import com.google.gson.JsonParser; +import org.junit.Test; + +import java.awt.image.BufferedImage; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public final class ModelFrontViewTest { + private static final String SEA_PICKLE = """ + [ + { "from": [6, 0, 6], "to": [10, 6, 10], "faces": { + "up": { "uv": [4, 1, 8, 5], "texture": "#all" }, + "south": { "uv": [0, 5, 4, 11], "texture": "#all" } } }, + { "from": [6, 5.95, 6], "to": [10, 5.95, 10], "faces": { + "up": { "uv": [8, 1, 12, 5], "texture": "#all" } } }, + { "from": [7.5, 5.2, 8], "to": [8.5, 8.7, 8], + "rotation": { "origin": [8, 8, 8], "axis": "y", "angle": 45, "rescale": true }, + "faces": { "south": { "uv": [3, 0, 1, 5], "texture": "#all" } } } + ] + """; + private static final String PRESSURE_PLATE = """ + [ + { "from": [1, 0, 1], "to": [15, 1, 15], "faces": { + "up": { "uv": [1, 1, 15, 15], "texture": "#all" }, + "south": { "uv": [1, 15, 15, 16], "texture": "#all" } } } + ] + """; + private static final Map TEXTURES = Map.of("all", "block/test"); + private static final int SIZE = 16; + + private final BufferedImage texture = everyTexelItsOwnColour(); + + @Test + public void bodyShowsItsOwnPartOfTheTexture() throws Exception { + ModelFrontView front = draw(SEA_PICKLE); + + assertEquals(texture.getRGB(0, 5), front.image().getRGB(6, 10)); + assertEquals(texture.getRGB(3, 10), front.image().getRGB(9, 15)); + } + + @Test + public void crossedPlaneIsMirroredWhereItsUvRunsBackwards() throws Exception { + ModelFrontView front = draw(SEA_PICKLE); + + assertEquals(texture.getRGB(1, 3), front.image().getRGB(8, 9)); + } + + @Test + public void spotsNoBoxCoversStayClear() throws Exception { + ModelFrontView front = draw(SEA_PICKLE); + + assertEquals(0, front.image().getRGB(2, 12) >>> 24); + assertEquals(0, front.image().getRGB(7, 2) >>> 24); + } + + @Test + public void smallShapeLeavesMostOfItsSpotOpen() { + assertFalse(ModelFrontView.fillsMostOfItsSpotFromAbove(elements(SEA_PICKLE))); + } + + @Test + public void flatShapeFillsMostOfItsSpot() { + assertTrue(ModelFrontView.fillsMostOfItsSpotFromAbove(elements(PRESSURE_PLATE))); + } + + private ModelFrontView draw(String elements) throws Exception { + ModelFrontView front = ModelFrontView.draw(elements(elements), TEXTURES, name -> texture); + assertNotNull(front); + return front; + } + + private static JsonArray elements(String json) { + return JsonParser.parseString(json).getAsJsonArray(); + } + + private static BufferedImage everyTexelItsOwnColour() { + BufferedImage image = new BufferedImage(SIZE, SIZE, BufferedImage.TYPE_INT_ARGB); + for (int y = 0; y < SIZE; y++) { + for (int x = 0; x < SIZE; x++) { + image.setRGB(x, y, 0xFF000000 | x << 16 | y << 8 | 0x40); + } + } + return image; + } +} From 360f38a819356666ad8d93ff1db645314d48c058 Mon Sep 17 00:00:00 2001 From: piexel Date: Sun, 25 Oct 2026 19:39:01 +0100 Subject: [PATCH 3/8] Show a newly picked object without moving the cursor A frame asked for while the last one was being taken, like the one that ends a load, was drawn and swapped before the earlier frame's swap, so the window kept the old object until something else redrew it. Model used: Claude Opus 5.5 --- .../main/java/art/arcane/iris/studio/map/OrbitViewport.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/art/arcane/iris/studio/map/OrbitViewport.java b/core/src/main/java/art/arcane/iris/studio/map/OrbitViewport.java index d9ac885eb..05886b3b1 100644 --- a/core/src/main/java/art/arcane/iris/studio/map/OrbitViewport.java +++ b/core/src/main/java/art/arcane/iris/studio/map/OrbitViewport.java @@ -382,17 +382,18 @@ final class OrbitViewport extends JComponent { /** * Until the card is opened, nothing knows whether frames can go into the window; the first picture opens it. A * frame that should have gone into the window but couldn't isn't tried again right away. A frame for a scene - * replaced meanwhile is never shown. + * replaced meanwhile is never shown. The frame counts as rendering until it is on its way to the screen, so a + * frame asked for while it is taken, like when it ends a load, can't be drawn over it first. */ private void finishFrame(OrbitScene frameScene, OrbitCamera frameCamera, ScreenTarget screen, OrbitPicture drawn, boolean missedScreen, long requestedAtNanos) { - rendering = false; measureFrameTime(System.nanoTime() - requestedAtNanos); if (frameScene == scene) { takePicture(drawn, frameCamera); paintPicture(drawn); showInWindow(screen, drawn); } + rendering = false; boolean canGoOnScreen = !missedScreen && canvas != null && !canvas.isVisible() && drawsOnScreen(); if (renderAgain || drawn.catchingUp() || canGoOnScreen) { renderAgain = false; From 065c6fed1daac932cadc464e63637cc947343f13 Mon Sep 17 00:00:00 2001 From: piexel Date: Sun, 25 Oct 2026 19:39:01 +0100 Subject: [PATCH 4/8] Draw small decoration tops in palette previews Icons now show each box's top above its front, half as deep and lit as Minecraft lights each side, so sea pickles, candles and eggs show caps. Model used: Claude Opus 5.5 --- .../iris/studio/map/ModelFrontView.java | 124 ++++++++++++------ .../iris/studio/map/ModelFrontViewTest.java | 17 ++- 2 files changed, 95 insertions(+), 46 deletions(-) diff --git a/core/src/main/java/art/arcane/iris/studio/map/ModelFrontView.java b/core/src/main/java/art/arcane/iris/studio/map/ModelFrontView.java index a984d7980..92438f9af 100644 --- a/core/src/main/java/art/arcane/iris/studio/map/ModelFrontView.java +++ b/core/src/main/java/art/arcane/iris/studio/map/ModelFrontView.java @@ -31,14 +31,18 @@ import java.util.List; import java.util.Map; /** - * A block model drawn flat as seen from the south, each box's south face with its own part of the texture. Small - * shapes like sea pickles and candles keep all their parts on one texture, which only makes sense mapped onto the - * boxes. Tilted boxes are drawn untilted, which is close for the thin crossed planes such shapes use. + * A block model drawn flat as seen from the south and a little above, each box's south and top faces with their own + * part of the texture. Small shapes like sea pickles and candles keep all their parts on one texture, which only makes + * sense mapped onto the boxes. A top face shows half as tall as the box is deep, and boxes further back sit higher, as + * in a three-quarter view, each face lit as Minecraft lights its side. Tilted boxes are drawn untilted, which is close + * for the thin crossed planes such shapes use. */ final class ModelFrontView { private static final double MODEL_UNITS = 16D; - private static final String FRONT = "south"; - private static final String TOP = "up"; + private static final double TOP_FORESHORTENING = 0.5D; + private static final CubeFace FRONT = CubeFace.SOUTH; + private static final CubeFace TOP = CubeFace.UP; + private static final int ALPHA_MASK = 0xFF000000; private static final double MOSTLY_COVERED_SHARE = 0.5D; private static final int X = 0; private static final int Y = 1; @@ -61,15 +65,15 @@ final class ModelFrontView { } /** - * @return the front view, or null when the model has no front faces with a texture + * @return the view, or null when the model has no front or top faces with a texture */ static ModelFrontView draw(JsonArray elements, Map textures, TextureImages images) throws IOException { - List faces = frontFacesNearestLast(elements, textures); + List faces = viewedFacesNearestLast(elements, textures); if (faces.isEmpty()) { return null; } Map loaded = new HashMap<>(); - for (FrontFace face : faces) { + for (ViewedFace face : faces) { BufferedImage texture = images.read(face.texture()); if (texture != null) { loaded.put(face.texture(), texture); @@ -79,16 +83,16 @@ final class ModelFrontView { return null; } int size = loaded.values().iterator().next().getWidth(); - BufferedImage front = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + BufferedImage view = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); int tintIndex = BlockTextures.NO_TINT_INDEX; - for (FrontFace face : faces) { + for (ViewedFace face : faces) { BufferedImage texture = loaded.get(face.texture()); if (texture != null) { - paint(face, texture, front); + paint(face, texture, view); tintIndex = tintIndex == BlockTextures.NO_TINT_INDEX ? face.tintIndex() : tintIndex; } } - return hasVisiblePixel(front) ? new ModelFrontView(front, tintIndex) : null; + return hasVisiblePixel(view) ? new ModelFrontView(view, tintIndex) : null; } /** @@ -105,7 +109,7 @@ final class ModelFrontView { for (JsonElement element : elements) { JsonObject box = element.getAsJsonObject(); JsonObject faces = box.getAsJsonObject("faces"); - if (faces == null || !faces.has(TOP)) { + if (faces == null || !faces.has(TOP.modelName())) { continue; } JsonArray from = box.getAsJsonArray("from"); @@ -127,28 +131,36 @@ final class ModelFrontView { return tintIndex; } - // Faces further south are nearer the viewer, so they are painted over the ones behind them. - private static List frontFacesNearestLast(JsonArray elements, Map textures) { - List faces = new ArrayList<>(); + // Boxes further south are nearer the viewer, and of two at the same depth the higher one hides the lower. + private static List viewedFacesNearestLast(JsonArray elements, Map textures) { + List faces = new ArrayList<>(); for (JsonElement element : elements) { JsonObject box = element.getAsJsonObject(); JsonObject boxFaces = box.getAsJsonObject("faces"); - if (boxFaces == null || !boxFaces.has(FRONT)) { + if (boxFaces == null) { continue; } - JsonObject face = boxFaces.getAsJsonObject(FRONT); - String texture = BlockTextures.dereference(face.get("texture").getAsString(), textures); - if (texture != null) { - faces.add(FrontFace.of(box, face, texture)); - } + addFace(faces, box, boxFaces, TOP, textures); + addFace(faces, box, boxFaces, FRONT, textures); } - faces.sort(Comparator.comparingDouble(FrontFace::depth)); + faces.sort(Comparator.comparingDouble(ViewedFace::depth).thenComparingDouble(ViewedFace::height)); return faces; } + private static void addFace(List faces, JsonObject box, JsonObject boxFaces, CubeFace side, Map textures) { + if (!boxFaces.has(side.modelName())) { + return; + } + JsonObject face = boxFaces.getAsJsonObject(side.modelName()); + String texture = BlockTextures.dereference(face.get("texture").getAsString(), textures); + if (texture != null) { + faces.add(side == TOP ? ViewedFace.top(box, face, texture) : ViewedFace.front(box, face, texture)); + } + } + // Each pixel samples the texture at its centre, along the face's UVs, which run backwards on mirrored faces. - private static void paint(FrontFace face, BufferedImage texture, BufferedImage front) { - int size = front.getWidth(); + private static void paint(ViewedFace face, BufferedImage texture, BufferedImage view) { + int size = view.getWidth(); double scale = size / MODEL_UNITS; double left = face.left() * scale; double right = face.right() * scale; @@ -163,7 +175,7 @@ final class ModelFrontView { int textureX = texel(face.u1() + alongU * (face.u2() - face.u1()), textureSize); int argb = texture.getRGB(textureX, textureY); if (argb >>> 24 != 0 && x >= 0 && y >= 0 && x < size && y < size) { - front.setRGB(x, y, argb); + view.setRGB(x, y, argb & ALPHA_MASK | BlockPreviewColors.shade(argb, face.side().shade())); } } } @@ -174,10 +186,10 @@ final class ModelFrontView { return Math.clamp((int) Math.floor(modelUnits / MODEL_UNITS * textureSize), 0, textureSize - 1); } - private static boolean hasVisiblePixel(BufferedImage front) { - for (int y = 0; y < front.getHeight(); y++) { - for (int x = 0; x < front.getWidth(); x++) { - if (front.getRGB(x, y) >>> 24 != 0) { + private static boolean hasVisiblePixel(BufferedImage view) { + for (int y = 0; y < view.getHeight(); y++) { + for (int x = 0; x < view.getWidth(); x++) { + if (view.getRGB(x, y) >>> 24 != 0) { return true; } } @@ -198,24 +210,50 @@ final class ModelFrontView { } /** - * A south face in model units, with the image's y running down from the model's top. + * A face as it shows in the view, in model units with y running down from the top of the view. The texture's v + * runs from the face's top edge in the view to its bottom edge. */ - private record FrontFace(String texture, int tintIndex, double left, double right, double top, double bottom, double depth, - double u1, double v1, double u2, double v2) { - static FrontFace of(JsonObject box, JsonObject face, String texture) { - JsonArray from = box.getAsJsonArray("from"); - JsonArray to = box.getAsJsonArray("to"); - double left = from.get(X).getAsDouble(); - double right = to.get(X).getAsDouble(); - double top = MODEL_UNITS - to.get(Y).getAsDouble(); - double bottom = MODEL_UNITS - from.get(Y).getAsDouble(); - double[] uv = face.has("uv") ? uvOf(face.getAsJsonArray("uv")) : new double[]{left, top, right, bottom}; - return new FrontFace(texture, BlockTextures.tintIndexOf(face), left, right, top, bottom, to.get(Z).getAsDouble(), - uv[0], uv[1], uv[2], uv[3]); + private record ViewedFace(CubeFace side, String texture, int tintIndex, double left, double right, double top, double bottom, + double depth, double height, double u1, double v1, double u2, double v2) { + static ViewedFace front(JsonObject box, JsonObject face, String texture) { + Box bounds = Box.of(box); + double top = bounds.viewY(bounds.toY(), bounds.toZ()); + double bottom = bounds.viewY(bounds.fromY(), bounds.toZ()); + double[] uv = face.has("uv") + ? uvOf(face.getAsJsonArray("uv")) + : new double[]{bounds.fromX(), MODEL_UNITS - bounds.toY(), bounds.toX(), MODEL_UNITS - bounds.fromY()}; + return new ViewedFace(FRONT, texture, BlockTextures.tintIndexOf(face), bounds.fromX(), bounds.toX(), top, bottom, + bounds.toZ(), bounds.toY(), uv[0], uv[1], uv[2], uv[3]); + } + + // The top's back edge is its north side, which the texture's v starts at. + static ViewedFace top(JsonObject box, JsonObject face, String texture) { + Box bounds = Box.of(box); + double top = bounds.viewY(bounds.toY(), bounds.fromZ()); + double bottom = bounds.viewY(bounds.toY(), bounds.toZ()); + double[] uv = face.has("uv") + ? uvOf(face.getAsJsonArray("uv")) + : new double[]{bounds.fromX(), bounds.fromZ(), bounds.toX(), bounds.toZ()}; + return new ViewedFace(TOP, texture, BlockTextures.tintIndexOf(face), bounds.fromX(), bounds.toX(), top, bottom, + bounds.toZ(), bounds.toY(), uv[0], uv[1], uv[2], uv[3]); } private static double[] uvOf(JsonArray uv) { return new double[]{uv.get(0).getAsDouble(), uv.get(1).getAsDouble(), uv.get(2).getAsDouble(), uv.get(3).getAsDouble()}; } } + + private record Box(double fromX, double fromY, double fromZ, double toX, double toY, double toZ) { + static Box of(JsonObject box) { + JsonArray from = box.getAsJsonArray("from"); + JsonArray to = box.getAsJsonArray("to"); + return new Box(from.get(X).getAsDouble(), from.get(Y).getAsDouble(), from.get(Z).getAsDouble(), + to.get(X).getAsDouble(), to.get(Y).getAsDouble(), to.get(Z).getAsDouble()); + } + + // The front edge of the block stays put; everything further back rises with its distance from it. + double viewY(double modelY, double modelZ) { + return MODEL_UNITS - modelY - (MODEL_UNITS - modelZ) * TOP_FORESHORTENING; + } + } } diff --git a/core/src/test/java/art/arcane/iris/studio/map/ModelFrontViewTest.java b/core/src/test/java/art/arcane/iris/studio/map/ModelFrontViewTest.java index bfb9a367a..8f1af975b 100644 --- a/core/src/test/java/art/arcane/iris/studio/map/ModelFrontViewTest.java +++ b/core/src/test/java/art/arcane/iris/studio/map/ModelFrontViewTest.java @@ -41,15 +41,22 @@ public final class ModelFrontViewTest { public void bodyShowsItsOwnPartOfTheTexture() throws Exception { ModelFrontView front = draw(SEA_PICKLE); - assertEquals(texture.getRGB(0, 5), front.image().getRGB(6, 10)); - assertEquals(texture.getRGB(3, 10), front.image().getRGB(9, 15)); + assertEquals(litAsFront(0, 5), front.image().getRGB(6, 7)); + assertEquals(litAsFront(3, 10), front.image().getRGB(9, 12)); + } + + @Test + public void topShowsAboveTheFrontOverThePlaneJustUnderIt() throws Exception { + ModelFrontView front = draw(SEA_PICKLE); + + assertEquals(texture.getRGB(5, 2), front.image().getRGB(7, 5)); } @Test public void crossedPlaneIsMirroredWhereItsUvRunsBackwards() throws Exception { ModelFrontView front = draw(SEA_PICKLE); - assertEquals(texture.getRGB(1, 3), front.image().getRGB(8, 9)); + assertEquals(litAsFront(1, 1), front.image().getRGB(8, 4)); } @Test @@ -70,6 +77,10 @@ public final class ModelFrontViewTest { assertTrue(ModelFrontView.fillsMostOfItsSpotFromAbove(elements(PRESSURE_PLATE))); } + private int litAsFront(int textureX, int textureY) { + return 0xFF000000 | BlockPreviewColors.shade(texture.getRGB(textureX, textureY), CubeFace.SOUTH.shade()); + } + private ModelFrontView draw(String elements) throws Exception { ModelFrontView front = ModelFrontView.draw(elements(elements), TEXTURES, name -> texture); assertNotNull(front); From 806a33976aff936e1fe3546fa8a72f83692179b9 Mon Sep 17 00:00:00 2001 From: piexel Date: Sun, 25 Oct 2026 19:58:08 +0100 Subject: [PATCH 5/8] Stop palette switches flashing a magnified texture Switching palettes dropped the last frame's timing, so the first frame fell back to one sample per ~29 pixels and drew a texture pixel in each square. The first frame is now sized from the previous source's speed, and coarse frames use block colours instead of texture pixels. Model used: Claude Opus 5.5 --- .../iris/studio/map/NoiseExplorerGUI.java | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/art/arcane/iris/studio/map/NoiseExplorerGUI.java b/core/src/main/java/art/arcane/iris/studio/map/NoiseExplorerGUI.java index 85f4305cc..bebaf20a1 100644 --- a/core/src/main/java/art/arcane/iris/studio/map/NoiseExplorerGUI.java +++ b/core/src/main/java/art/arcane/iris/studio/map/NoiseExplorerGUI.java @@ -99,6 +99,7 @@ public final class NoiseExplorerGUI extends JPanel { private static final int REFINEMENT_DELAY_MILLIS = 180; private static final long DEFAULT_SEED = 12345L; private static final double TEXTURE_PIXELS_MAX_BLOCKS_PER_PIXEL = 0.25D; + private static final int FULL_DETAIL_SAMPLE_STEP = 1; private static final double PALETTE_START_BLOCKS_PER_PIXEL = 1D / 16D; private static final long COARSE_SAMPLE_BUDGET = 4_096L; private static final double REFINEMENT_TARGET_MILLIS = 250D; @@ -176,6 +177,7 @@ public final class NoiseExplorerGUI extends JPanel { private PaletteLayerPreview palettePreview; private int paletteDepth; private NoiseRenderCoordinator.Result renderResult; + private NoiseRenderCoordinator.Result lastTimedFrame; private NoiseRenderCoordinator.Request interactiveFrame; private boolean interactiveFramePending; private Point pointer; @@ -991,12 +993,17 @@ public final class NoiseExplorerGUI extends JPanel { } private NoiseColorScale activeColorScale() { + return activeColorScale(FULL_DETAIL_SAMPLE_STEP); + } + + // A coarse frame's sample stands for a whole square of pixels, so texture pixels would show magnified there. + private NoiseColorScale activeColorScale(int sampleStep) { PaletteLayerPreview preview = palettePreview; if (preview == null) { BlockRange blockRange = samplerBlockRange; return blockRange == null ? palette : new BlockHeightColorScale(palette, blockRange.lowest(), blockRange.highest()); } - boolean showTexturePixels = viewport.blocksPerPixel() <= TEXTURE_PIXELS_MAX_BLOCKS_PER_PIXEL; + boolean showTexturePixels = viewport.blocksPerPixel() * sampleStep <= TEXTURE_PIXELS_MAX_BLOCKS_PER_PIXEL; return new PaletteLayerScale(paletteInspector.selectedView(), preview, palette, showTexturePixels); } @@ -1106,9 +1113,10 @@ public final class NoiseExplorerGUI extends JPanel { return interactiveFrame != null && interactiveFrame.revision() == viewRevision; } - // The last frame's speed predicts this one, so cheap views draw sharp straight away instead of starting blocky. + // The last frame's speed predicts this one, so cheap views draw sharp straight away instead of starting blocky. It + // outlives a switch to another source, which samples about as fast. private long interactiveSampleBudget() { - NoiseRenderCoordinator.Result lastResult = renderResult; + NoiseRenderCoordinator.Result lastResult = lastTimedFrame; if (lastResult == null) { return COARSE_SAMPLE_BUDGET; } @@ -1166,7 +1174,7 @@ public final class NoiseExplorerGUI extends JPanel { viewRevision, sampler, viewport, - activeColorScale(), + activeColorScale(sampleStep), getWidth(), renderHeight(), sampleStep @@ -1680,6 +1688,7 @@ public final class NoiseExplorerGUI extends JPanel { rendering = false; errorMessage = null; renderResult = result; + lastTimedFrame = result; repaint(); refreshStatus(); if (finishInteractiveFrame(result.request())) { From 547c52540a764ffc5151e7ecc970d23f18799fe5 Mon Sep 17 00:00:00 2001 From: piexel Date: Sun, 25 Oct 2026 20:06:30 +0100 Subject: [PATCH 6/8] Crossfade between palettes instead of flashing empty Switching source kept nothing on screen until the new one drew. The old frame now stays until the first new frame lands, then fades out over it in 150 ms. Model used: Claude Opus 5.5 --- .../iris/studio/map/FrameCrossfade.java | 100 ++++++++++++++++++ .../iris/studio/map/NoiseExplorerGUI.java | 21 ++++ .../iris/studio/map/FrameCrossfadeTest.java | 62 +++++++++++ 3 files changed, 183 insertions(+) create mode 100644 core/src/main/java/art/arcane/iris/studio/map/FrameCrossfade.java create mode 100644 core/src/test/java/art/arcane/iris/studio/map/FrameCrossfadeTest.java diff --git a/core/src/main/java/art/arcane/iris/studio/map/FrameCrossfade.java b/core/src/main/java/art/arcane/iris/studio/map/FrameCrossfade.java new file mode 100644 index 000000000..8b608ec90 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/FrameCrossfade.java @@ -0,0 +1,100 @@ +/* + * 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 art.arcane.iris.studio.map; + +import javax.swing.Timer; +import java.util.concurrent.TimeUnit; + +/** + * Keeps the last frame of what the explorer showed before on screen while the next thing loads, then fades it out over + * the first frame of the new one, so switching never shows an empty view in between. + */ +final class FrameCrossfade { + private static final long FADE_NANOS = TimeUnit.MILLISECONDS.toNanos(150L); + private static final int REPAINT_INTERVAL_MILLIS = 16; + + private final Timer repaintTimer; + private Phase phase = Phase.NONE; + private NoiseRenderCoordinator.Result outgoing; + private long fadeStartedNanos; + + FrameCrossfade(Runnable repaint) { + this.repaintTimer = new Timer(REPAINT_INTERVAL_MILLIS, event -> repaint.run()); + } + + /** + * Keeps showing the frame until the next one arrives. Without a frame, as when the switch before hasn't drawn yet, + * the frame already held stays. + */ + void hold(NoiseRenderCoordinator.Result frame) { + if (frame == null && outgoing != null) { + phase = Phase.HOLDING; + return; + } + enter(frame == null ? Phase.NONE : Phase.HOLDING, frame); + } + + /** + * The new frame is in, so the held one starts fading out over it. + */ + void reveal() { + if (phase != Phase.HOLDING) { + return; + } + fadeStartedNanos = System.nanoTime(); + phase = Phase.FADING; + repaintTimer.start(); + } + + void clear() { + enter(Phase.NONE, null); + } + + /** + * @return the frame to draw over the current one, or null when there is none + */ + NoiseRenderCoordinator.Result outgoing() { + if (phase == Phase.FADING && fadedShare() >= 1F) { + clear(); + } + return outgoing; + } + + float outgoingOpacity() { + return phase == Phase.FADING ? 1F - fadedShare() : 1F; + } + + private float fadedShare() { + return Math.min(1F, (System.nanoTime() - fadeStartedNanos) / (float) FADE_NANOS); + } + + private void enter(Phase nextPhase, NoiseRenderCoordinator.Result frame) { + phase = nextPhase; + outgoing = frame; + if (nextPhase != Phase.FADING) { + repaintTimer.stop(); + } + } + + private enum Phase { + NONE, + HOLDING, + FADING + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/NoiseExplorerGUI.java b/core/src/main/java/art/arcane/iris/studio/map/NoiseExplorerGUI.java index bebaf20a1..bb052c15b 100644 --- a/core/src/main/java/art/arcane/iris/studio/map/NoiseExplorerGUI.java +++ b/core/src/main/java/art/arcane/iris/studio/map/NoiseExplorerGUI.java @@ -61,6 +61,8 @@ import java.awt.Component; import java.awt.Dimension; import java.awt.EventQueue; import java.awt.Graphics; +import java.awt.AlphaComposite; +import java.awt.Composite; import java.awt.Graphics2D; import java.awt.Point; import java.awt.RenderingHints; @@ -143,6 +145,7 @@ public final class NoiseExplorerGUI extends JPanel { private final NoiseRenderCoordinator renderCoordinator; private final Timer refinementTimer; private final Timer resizeTimer; + private final FrameCrossfade crossfade; private final AtomicLong sourceLoadRevision = new AtomicLong(); private final AtomicLong catalogLoadRevision = new AtomicLong(); private final Runnable hotloadHook = this::handleHotload; @@ -227,6 +230,7 @@ public final class NoiseExplorerGUI extends JPanel { this.refinementTimer.setRepeats(false); this.resizeTimer = new Timer(RESIZE_SETTLE_MILLIS, event -> requestPreview()); this.resizeTimer.setRepeats(false); + this.crossfade = new FrameCrossfade(this::repaint); this.fixedSources = buildFixedSources(); setBackground(StudioTheme.EDITOR_BACKGROUND); setFocusable(true); @@ -826,6 +830,7 @@ public final class NoiseExplorerGUI extends JPanel { selectedSource = null; clearSampler(); renderResult = null; + crossfade.clear(); sourceLoading = false; rendering = false; errorMessage = "Pack source removed during hotload: " + removedSource.text(); @@ -918,6 +923,7 @@ public final class NoiseExplorerGUI extends JPanel { viewRevision++; renderCoordinator.cancel(viewRevision); sampler = null; + crossfade.hold(renderResult); renderResult = null; sourceLoading = true; rendering = false; @@ -963,6 +969,7 @@ public final class NoiseExplorerGUI extends JPanel { return; } clearSampler(); + crossfade.clear(); sourceLoading = false; rendering = false; errorMessage = readableError(error); @@ -1292,6 +1299,7 @@ public final class NoiseExplorerGUI extends JPanel { } refinementTimer.stop(); resizeTimer.stop(); + crossfade.clear(); MapHost.unregisterHotloadHook(hotloadHook); sourceLoadRevision.incrementAndGet(); catalogLoadRevision.incrementAndGet(); @@ -1316,6 +1324,7 @@ public final class NoiseExplorerGUI extends JPanel { if (renderResult != null) { drawLatestFrame(canvas, renderResult, getWidth(), renderHeight); } + drawOutgoingFrame(canvas, getWidth(), renderHeight); drawCrosshair(canvas, getWidth(), renderHeight); drawLegend(canvas, getWidth()); } finally { @@ -1337,6 +1346,17 @@ public final class NoiseExplorerGUI extends JPanel { return true; } + private void drawOutgoingFrame(Graphics2D canvas, int width, int renderHeight) { + NoiseRenderCoordinator.Result outgoing = crossfade.outgoing(); + if (outgoing == null) { + return; + } + Composite opaque = canvas.getComposite(); + canvas.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, crossfade.outgoingOpacity())); + drawLatestFrame(canvas, outgoing, width, renderHeight); + canvas.setComposite(opaque); + } + // Moves and scales the last finished frame to the current view, so the map follows the mouse before the next frame lands. private void drawLatestFrame(Graphics2D canvas, NoiseRenderCoordinator.Result frame, int width, int renderHeight) { NoiseRenderCoordinator.Request request = frame.request(); @@ -1689,6 +1709,7 @@ public final class NoiseExplorerGUI extends JPanel { errorMessage = null; renderResult = result; lastTimedFrame = result; + crossfade.reveal(); repaint(); refreshStatus(); if (finishInteractiveFrame(result.request())) { diff --git a/core/src/test/java/art/arcane/iris/studio/map/FrameCrossfadeTest.java b/core/src/test/java/art/arcane/iris/studio/map/FrameCrossfadeTest.java new file mode 100644 index 000000000..ab122bd5e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/studio/map/FrameCrossfadeTest.java @@ -0,0 +1,62 @@ +package art.arcane.iris.studio.map; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public final class FrameCrossfadeTest { + private final FrameCrossfade crossfade = new FrameCrossfade(() -> { + }); + + @Test + public void heldFrameStaysFullyVisibleUntilTheNextFrameArrives() { + NoiseRenderCoordinator.Result frame = frame(); + + crossfade.hold(frame); + + assertSame(frame, crossfade.outgoing()); + assertEquals(1F, crossfade.outgoingOpacity(), 0F); + } + + @Test + public void heldFrameFadesOnceTheNextFrameArrives() throws InterruptedException { + crossfade.hold(frame()); + crossfade.reveal(); + Thread.sleep(20L); + + assertTrue(crossfade.outgoingOpacity() < 1F); + } + + @Test + public void switchingAgainBeforeAnythingDrewKeepsTheFrameHeld() { + NoiseRenderCoordinator.Result frame = frame(); + crossfade.hold(frame); + + crossfade.hold(null); + + assertSame(frame, crossfade.outgoing()); + } + + @Test + public void frameWithoutAHeldFrameHasNothingToFade() { + crossfade.reveal(); + + assertNull(crossfade.outgoing()); + } + + @Test + public void clearDropsTheHeldFrame() { + crossfade.hold(frame()); + + crossfade.clear(); + + assertNull(crossfade.outgoing()); + } + + private static NoiseRenderCoordinator.Result frame() { + return new NoiseRenderCoordinator.Result(null, null, 1D, 1L, 0D, 1D, 0.5D, 0L, 0L, 0L); + } +} From 6900f270ee4edb6cad26ab6a7dd8bf9825a59fd2 Mon Sep 17 00:00:00 2001 From: piexel Date: Sun, 25 Oct 2026 21:42:44 +0100 Subject: [PATCH 7/8] Shorten the palette crossfade to 70 ms The 150 ms fade made switching feel sluggish. Model used: Opus 5.5 --- .../main/java/art/arcane/iris/studio/map/FrameCrossfade.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/java/art/arcane/iris/studio/map/FrameCrossfade.java b/core/src/main/java/art/arcane/iris/studio/map/FrameCrossfade.java index 8b608ec90..45c7d949b 100644 --- a/core/src/main/java/art/arcane/iris/studio/map/FrameCrossfade.java +++ b/core/src/main/java/art/arcane/iris/studio/map/FrameCrossfade.java @@ -26,7 +26,7 @@ import java.util.concurrent.TimeUnit; * the first frame of the new one, so switching never shows an empty view in between. */ final class FrameCrossfade { - private static final long FADE_NANOS = TimeUnit.MILLISECONDS.toNanos(150L); + private static final long FADE_NANOS = TimeUnit.MILLISECONDS.toNanos(70L); private static final int REPAINT_INTERVAL_MILLIS = 16; private final Timer repaintTimer; From e3bc27d50c0162f36c761c6e75138a13c45e235f Mon Sep 17 00:00:00 2001 From: piexel Date: Sun, 25 Oct 2026 21:49:03 +0100 Subject: [PATCH 8/8] Keep hidden 3D views from covering the open view A 3D view asked for a frame while its card was hidden, for example when the sidebar resized, showed its GPU window again. AWT maps a shown canvas even under a hidden parent, so the window covered the open view in black. Frames only go into the window while the view is on screen. Model used: Opus 5.5 --- .../java/art/arcane/iris/studio/map/OrbitViewport.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/art/arcane/iris/studio/map/OrbitViewport.java b/core/src/main/java/art/arcane/iris/studio/map/OrbitViewport.java index 05886b3b1..10de846f0 100644 --- a/core/src/main/java/art/arcane/iris/studio/map/OrbitViewport.java +++ b/core/src/main/java/art/arcane/iris/studio/map/OrbitViewport.java @@ -354,8 +354,9 @@ final class OrbitViewport extends JComponent { } /** - * Frames go straight into the window when the scene can be drawn that way, Swing has nothing to paint over the - * whole view, and the screen's pixels are the view's own. The window is shown before the frame is drawn into it. + * Frames go straight into the window when the view is on screen, the scene can be drawn that way, Swing has nothing + * to paint over the whole view, and the screen's pixels are the view's own. The window is shown before the frame is + * drawn into it. Showing it maps it even while its card is hidden, where it would cover the card that is open. * * @return the window, or nothing to draw a picture for Swing instead */ @@ -370,8 +371,8 @@ final class OrbitViewport extends JComponent { } private boolean drawsOnScreen() { - return canvas != null && canvas.window() != 0L && scene != null && !scene.isEmpty() && scene.canRenderOnScreen() - && !isLoadingShown() && hasUnscaledPixels(); + return canvas != null && isShowing() && canvas.window() != 0L && scene != null && !scene.isEmpty() + && scene.canRenderOnScreen() && !isLoadingShown() && hasUnscaledPixels(); } private boolean hasUnscaledPixels() {