From 2d56a1910ce932035d7be3466afbeb7ec6ad2132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 4 Nov 2026 16:03:34 +0100 Subject: [PATCH] render item frame contents with the item geometry like vanilla ItemFrameRenderer (fixed context, 8 rotations, all facings), frame block models, map contents incl. pixelpics photos, invisible frames, head skins/banner patterns from item meta --- .../pixelpics/assets/BlockModelRegistry.java | 12 ++ .../render/entity/DecorationBaker.java | 136 +++++++++-------- .../render/entity/DecorationState.java | 11 +- .../render/entity/cem/ItemGeometry.java | 8 +- .../render/render/DefaultScreenRenderer.java | 2 +- .../snapshot/BlockEntitySnapshotBuilder.java | 49 ++++++ .../snapshot/DecorationSnapshotBuilder.java | 23 +-- .../pixelpics/render/snapshot/ItemSpecs.java | 7 +- .../pixelpics/render/snapshot/MapPixels.java | 81 ++++++++++ .../pixelpics/utils/ImageMapRenderer.java | 5 + .../pixelpics/utils/MapColorPalette.java | 16 ++ tools/BlockEntityTestRender.java | 15 +- tools/edgecase-plugin/pptest/PPTest.java | 139 +++++++++++++++++- 13 files changed, 408 insertions(+), 96 deletions(-) create mode 100644 src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/MapPixels.java diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockModelRegistry.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockModelRegistry.java index f16d851..211a1c4 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockModelRegistry.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockModelRegistry.java @@ -26,6 +26,7 @@ public final class BlockModelRegistry { private final ModelBaker baker; private final Map cache = new ConcurrentHashMap<>(); + private final Map modelCache = new ConcurrentHashMap<>(); private volatile ResolvedModel waterModel; private volatile ResolvedModel lavaModel; @@ -48,6 +49,17 @@ public final class BlockModelRegistry { return cache.computeIfAbsent(data, this::resolve); } + /** + * A block model by id (e.g. {@code block/item_frame_map}) without a blockstate, unrotated: for models + * vanilla draws from a fake block state (item frames). Empty geometry when the model is missing. + */ + public ResolvedModel model(ResourceLocation modelId) { + return modelCache.computeIfAbsent(modelId, id -> { + ModelBaker.BakedGeometry baked = baker.bake(modelResolver.resolve(id), new Variant(id, 0, 0, false)); + return new ResolvedModel(baked.elements(), baked.averageColor(), 0, 0, false, baked.hasGeometry()); + }); + } + private ResolvedModel resolve(BlockData data) { Material material = data.getMaterial(); if (material == Material.WATER) return water(); diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationBaker.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationBaker.java index 867d54e..0bb9c01 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationBaker.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationBaker.java @@ -1,25 +1,31 @@ package eu.mhsl.minecraft.pixelpics.render.entity; +import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry; import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation; import eu.mhsl.minecraft.pixelpics.assets.TextureCache; import eu.mhsl.minecraft.pixelpics.assets.model.Direction; import eu.mhsl.minecraft.pixelpics.assets.model.Face; +import eu.mhsl.minecraft.pixelpics.render.entity.cem.ItemGeometry; import java.util.ArrayList; import java.util.List; /** - * Bakes flat wall decorations (paintings, item frames) into world-space {@link EntityCube}s. These are - * not CEM models but simple textured quads, so the geometry is built directly from the captured world - * bounding box and the front-facing direction. Added to the {@link EntityScene} like any other entity. + * Bakes wall decorations into world-space {@link EntityCube}s: paintings as flat textured quads built from the + * captured world bounding box, item frames as their vanilla frame block model plus the framed item (item + * model geometry) or map. Added to the {@link EntityScene} like any other entity. */ public final class DecorationBaker implements EntityBaker { private static final double MIN_THICKNESS = 0.0625; // 1px, so the slab test never degenerates private final TextureCache textures; + private final BlockModelRegistry registry; + private final ItemGeometry items; - public DecorationBaker(TextureCache textures) { + public DecorationBaker(TextureCache textures, BlockModelRegistry registry, ItemGeometry items) { this.textures = textures; + this.registry = registry; + this.items = items; } @Override @@ -46,77 +52,69 @@ public final class DecorationBaker implements EntityBaker { } /** - * Item frames render as the vanilla geometry: a 12x12 birch border, a recessed 10x10 leather back - * panel ({@code block/item_frame}), and the held item as a centred 8x8 sprite. Built in a canonical - * local frame (front toward +Z, centred on the block face) and oriented to the wall by an affine. + * Item frames as vanilla's ItemFrameRenderer (decompiled 26.3) draws them. Pose from the entity position + * (the bbox centre; the renderer's own offset cancels the dispatcher's): {@code T(dir·0.46875)} to the + * block centre, then {@code rotX(xRot)·rotY(yRot)} (horizontal: 0 / 180−yRot(dir); up/down: ∓90 / 180), + * which turns local +Z toward the wall. Frame = the fake-blockstate block model + * ({@code block/[glow_]item_frame[_map]}) at {@code T(-0.5)}, skipped when invisible. Content at + * {@code T(0,0,0.4375)} ({@code 0.5} when invisible): a map as a 128x128 quad + * ({@code rotZ(rot%4·90)·rotZ(180)·S(1/128)·T(-64,-64,-1)}, z −0.01 map px, MapRenderer), any other item + * through the item model in the {@code fixed} display context at {@code rotZ(rot·45)·S(0.5)}. */ private RenderedEntity bakeItemFrame(DecorationState s) { - String frameTex = s.glow() ? "block/glow_item_frame" : "block/item_frame"; - int[][] leather = textures.get(ResourceLocation.parse(frameTex)).orElse(null); - int[][] wood = - textures.get(ResourceLocation.parse("block/birch_planks")).orElse(leather); - if (leather == null) return null; - - Affine toWorld = Affine.translation(faceCenterX(s), faceCenterY(s), faceCenterZ(s)) - .mul(facingRotation(s.facing())); - int si = Direction.SOUTH.ordinal(); - - List cubes = new ArrayList<>(3); - // Wood border 12x12 (behind), leather back 10x10 (1px proud), item 8x8 (in front). Front = local +Z. - // `wood` is never null here (orElse(leather) and leather is non-null past the guard above). - Face[] woodFace = new Face[6]; - woodFace[si] = new Face(wood, 1, 0, 0, 1, 0, -1); // tileable; flip matches the others - cubes.add(new EntityCube(px(-6, -6, 0), px(6, 6, 1), woodFace, toWorld)); - Face[] back = new Face[6]; - back[si] = new Face(leather, 13.0 / 16, 3.0 / 16, 3.0 / 16, 13.0 / 16, 0, -1); // centre 10x10, flipped - cubes.add(new EntityCube(px(-5, -5, 1), px(5, 5, 2), back, toWorld)); - - if (s.itemId() != null) { - int[][] item = resolveItem(s.itemId()); - if (item != null) { - int rot = ((Math.round(s.itemRotationDeg() / 90f) * 90) % 360 + 360) % 360; - Face[] f = new Face[6]; - f[si] = new Face(item, 1, 0, 0, 1, rot, -1); // full sprite, flipped to read upright - cubes.add(new EntityCube(px(-4, -4, 2), px(4, 4, 3), f, toWorld)); - } + DecorationState.Facing f = s.facing(); + double off = 0.46875; + double cx = (s.minX() + s.maxX()) / 2 + (f.axis() == 0 ? f.sign() * off : 0); + double cy = (s.minY() + s.maxY()) / 2 + (f.axis() == 1 ? f.sign() * off : 0); + double cz = (s.minZ() + s.maxZ()) / 2 + (f.axis() == 2 ? f.sign() * off : 0); + double xRot, yRot; + if (f.axis() != 1) { + xRot = 0; + yRot = 180 - yRot(f); + } else { + xRot = -90 * f.sign(); + yRot = 180; } - return RenderedEntity.of(cubes); + Affine pose = Affine.translation(cx, cy, cz) + .mul(Affine.rotX(Math.toRadians(xRot))) + .mul(Affine.rotY(Math.toRadians(yRot))); + + List cubes = new ArrayList<>(); + boolean map = s.mapPixels() != null; + if (!s.invisible() && registry != null && items != null) { + String model = "block/" + (s.glow() ? "glow_item_frame" : "item_frame") + (map ? "_map" : ""); + items.bakeModel( + registry.model(ResourceLocation.parse(model)), + null, + pose.mul(Affine.translation(-0.5, -0.5, -0.5)), + cubes); + } + Affine content = pose.mul(Affine.translation(0, 0, s.invisible() ? 0.5 : 0.4375)); + if (map) { + Affine m = content.mul(Affine.rotZ(Math.toRadians(s.rotation() % 4 * 90))) + .mul(Affine.rotZ(Math.PI)) + .mul(Affine.scale(1 / 128.0)) + .mul(Affine.translation(-64, -64, -1)); + // Quad (0,0)..(128,128) at z −0.01 facing local −Z (away from the wall); u along +x, v along +y. + Face[] faces = new Face[6]; + faces[Direction.NORTH.ordinal()] = new Face(s.mapPixels(), 1, 1, 0, 0, 0, -1); + faces[Direction.SOUTH.ordinal()] = new Face(s.mapPixels(), 0, 1, 1, 0, 0, -1); + cubes.add(new EntityCube(new double[] {0, 0, -0.01}, new double[] {128, 128, -0.01}, faces, m)); + } else if (s.item() != null && items != null) { + Affine m = + content.mul(Affine.rotZ(Math.toRadians(s.rotation() * 45))).mul(Affine.scale(0.5)); + items.bake(s.item(), "fixed", false, m, cubes); + } + return cubes.isEmpty() ? null : RenderedEntity.of(cubes); } - /** A local-space corner in model pixels (1/16 block); z is the outward (front) offset from the wall. */ - private static double[] px(double x, double y, double z) { - return new double[] {x / 16.0, y / 16.0, z / 16.0}; - } - - /** Item sprite: generated items live under item/, block items fall back to block/. */ - private int[][] resolveItem(String id) { - int[][] t = textures.get(ResourceLocation.parse("item/" + id)).orElse(null); - if (t != null) return t; - return textures.get(ResourceLocation.parse("block/" + id)).orElse(null); - } - - // Frame placement: in-plane centre from the bbox, depth at the outward wall surface. - private static double faceCenterX(DecorationState s) { - return s.facing().axis() == 0 ? (s.facing().sign() > 0 ? s.maxX() : s.minX()) : (s.minX() + s.maxX()) / 2; - } - - private static double faceCenterY(DecorationState s) { - return s.facing().axis() == 1 ? (s.facing().sign() > 0 ? s.maxY() : s.minY()) : (s.minY() + s.maxY()) / 2; - } - - private static double faceCenterZ(DecorationState s) { - return s.facing().axis() == 2 ? (s.facing().sign() > 0 ? s.maxZ() : s.minZ()) : (s.minZ() + s.maxZ()) / 2; - } - - /** Maps the canonical local frame (front +Z, up +Y) onto the wall facing the given direction. */ - private static Affine facingRotation(DecorationState.Facing f) { + /** Vanilla Direction#toYRot for horizontal facings (south 0, west 90, north 180, east 270). */ + private static double yRot(DecorationState.Facing f) { return switch (f) { - case SOUTH -> Affine.identity(); - case NORTH -> Affine.rotY(Math.PI); - case EAST -> Affine.rotY(Math.PI / 2); - case WEST -> Affine.rotY(-Math.PI / 2); - case UP -> Affine.rotX(-Math.PI / 2); - case DOWN -> Affine.rotX(Math.PI / 2); + case WEST -> 90; + case NORTH -> 180; + case EAST -> 270; + default -> 0; }; } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationState.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationState.java index e8abc95..4f3ecb9 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationState.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationState.java @@ -4,7 +4,8 @@ package eu.mhsl.minecraft.pixelpics.render.entity; * Immutable snapshot of a flat wall decoration (painting or item frame). These are Bukkit * {@code Hanging} entities, not block-entities, but render as flat textured quads rather than CEM * models, so they have their own state/baker. The world-space bounding box is captured directly (it - * already encodes vanilla's awkward multi-block painting offset), avoiding placement math. + * already encodes vanilla's awkward multi-block painting offset), avoiding placement math. Item frames use + * its centre (the vanilla entity position). */ public record DecorationState( Kind kind, @@ -16,9 +17,11 @@ public record DecorationState( double maxZ, Facing facing, // direction the front faces (away from the wall) String paintingArt, // painting art asset key (texture painting/); null for frames - String itemId, // item-frame contents material key (e.g. "diamond"); null if empty - int itemRotationDeg, // item-frame content rotation (0/45/…/315) - boolean glow // glow item frame + ItemSpec item, // item-frame contents; null if empty + int[][] mapPixels, // framed map's 128x128 ARGB colours (row-major [y][x]); null if not a map with data + int rotation, // item-frame content rotation in 45° steps (0..7, vanilla ItemFrame#getRotation) + boolean glow, // glow item frame + boolean invisible // invisible frame (setVisible(false)): no frame model, item sits on the wall ) { public enum Kind { PAINTING, diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/ItemGeometry.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/ItemGeometry.java index c242901..7cf7056 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/ItemGeometry.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/ItemGeometry.java @@ -107,7 +107,11 @@ public final class ItemGeometry { /** A block's model elements in the block cube [0,1]³ (tinted with plains colours). */ public void bakeBlock(BlockData data, Affine toWorld, List out) { if (data == null) return; - ResolvedModel model = registry.get(data); + bakeModel(registry.get(data), data, toWorld, out); + } + + /** A resolved block model's elements in the block cube [0,1]³; {@code data} = tint context or null. */ + public void bakeModel(ResolvedModel model, BlockData data, Affine toWorld, List out) { if (model == null || !model.hasGeometry) return; for (Element world : model.elements) { // Rotated: bake the model-space element turned by the blockstate rotation, so textures stay bound @@ -467,7 +471,7 @@ public final class ItemGeometry { /** Bakes a biome tint into a copy of the face texture (the entity path has no per-hit tinting). */ private Face tint(Face f, BlockData data) { - if (f == null || f.tintIndex < 0) return f; + if (f == null || f.tintIndex < 0 || data == null) return f; int color = TintResolver.resolve(data, f.tintIndex, DEFAULT_TINT); if (color == -1) return f; int[][] t; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java index 4ca8b67..ed9dec8 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java @@ -88,7 +88,6 @@ public class DefaultScreenRenderer implements Renderer { this.raytracer = new SnapshotRaytracer(registry, tintProvider, skyRenderer, MAX_DISTANCE, REFLECTION_DEPTH); this.entityBaker = entityBaker; this.blockEntityBaker = blockEntityBaker; - this.decorationBaker = new DecorationBaker(textures); ItemGeometry items = new ItemGeometry( registry.reader(), registry, @@ -96,6 +95,7 @@ public class DefaultScreenRenderer implements Renderer { entityBaker == null ? null : entityBaker.models(), blockEntityBaker); if (entityBaker != null) entityBaker.setItemGeometry(items); + this.decorationBaker = new DecorationBaker(textures, registry, items); this.objectBaker = new ObjectBaker(items, blockEntityBaker == null ? null : blockEntityBaker.font()); this.logger = logger; this.tracePool = tracePool; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java index 0b17c54..69447e4 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java @@ -29,6 +29,9 @@ import org.bukkit.block.data.type.Chest; import org.bukkit.block.data.type.Lectern; import org.bukkit.block.sign.Side; import org.bukkit.block.sign.SignSide; +import org.bukkit.inventory.meta.BannerMeta; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.inventory.meta.SkullMeta; import org.bukkit.profile.PlayerProfile; import org.bukkit.util.Vector; @@ -89,6 +92,52 @@ public final class BlockEntitySnapshotBuilder { } } + /** + * Adds the item-stack details a placed tile would carry to a {@link #fromBlockData} state: the owner skin + * of a player head ({@code SkullMeta} profile) and banner patterns ({@code BannerMeta}). Other kinds are + * returned unchanged. MUST run on the main thread. + */ + public static BlockEntityState withItemMeta(BlockEntityState s, ItemMeta meta) { + if (s == null || meta == null) return s; + try { + if (meta instanceof SkullMeta skull && "player".equals(s.headType())) { + PlayerProfile profile = skull.getOwnerProfile(); + if (profile == null || profile.getTextures().getSkin() == null) return s; + return copy(s, profile.getTextures().getSkin().toString(), s.patterns()); + } + if (meta instanceof BannerMeta banner && !banner.getPatterns().isEmpty()) { + List pats = new ArrayList<>(); + for (Pattern p : banner.getPatterns()) { + pats.add(new BannerPattern(patternKey(p), ColorUtil.dyeArgb(p.getColor(), 0xFFFFFFFF))); + } + return copy(s, s.skinUrl(), pats); + } + } catch (Throwable ignored) { + // API drift: keep the plain block state + } + return s; + } + + private static BlockEntityState copy(BlockEntityState s, String skinUrl, List patterns) { + return new BlockEntityState( + s.kind(), + s.bx(), + s.by(), + s.bz(), + s.facingDeg(), + s.direction(), + s.active(), + s.chestKind(), + s.baseColorArgb(), + s.colorName(), + s.headType(), + skinUrl, + patterns, + s.sherds(), + s.frontText(), + s.backText()); + } + private static BlockEntityState classify(BlockData data, int bx, int by, int bz, BlockState ts) { Material mat = data.getMaterial(); String n = mat.name(); diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/DecorationSnapshotBuilder.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/DecorationSnapshotBuilder.java index a5d626a..4d61518 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/DecorationSnapshotBuilder.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/DecorationSnapshotBuilder.java @@ -1,9 +1,11 @@ package eu.mhsl.minecraft.pixelpics.render.snapshot; import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState; +import eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec; import java.util.ArrayList; import java.util.Collection; import java.util.List; +import org.bukkit.Material; import org.bukkit.block.BlockFace; import org.bukkit.entity.Entity; import org.bukkit.entity.GlowItemFrame; @@ -49,15 +51,18 @@ public final class DecorationSnapshotBuilder { facing(e.getFacing()), art, null, + null, 0, + false, false); } if (e instanceof ItemFrame frame) { BoundingBox bb = e.getBoundingBox(); boolean glow = e instanceof GlowItemFrame || e.getType().getKey().getKey().equals("glow_item_frame"); - String itemId = itemId(frame.getItem()); - int rot = frame.getRotation().ordinal() * 45; + ItemStack stack = frame.getItem(); + ItemSpec item = ItemSpecs.of(stack); + int[][] map = item != null && stack.getType() == Material.FILLED_MAP ? MapPixels.of(stack) : null; return new DecorationState( DecorationState.Kind.ITEM_FRAME, bb.getMinX(), @@ -68,19 +73,15 @@ public final class DecorationSnapshotBuilder { bb.getMaxZ(), facing(e.getFacing()), null, - itemId, - rot, - glow); + item, + map, + frame.getRotation().ordinal(), + glow, + !frame.isVisible()); } return null; } - /** The framed item's material key (e.g. "diamond"); the baker resolves item/ or block/ textures from it. */ - private static String itemId(ItemStack item) { - if (item == null || item.getType().isAir()) return null; - return item.getType().getKey().getKey(); - } - private static DecorationState.Facing facing(BlockFace face) { return switch (face) { case NORTH -> DecorationState.Facing.NORTH; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/ItemSpecs.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/ItemSpecs.java index 08898c7..8b06c04 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/ItemSpecs.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/ItemSpecs.java @@ -29,7 +29,12 @@ public final class ItemSpecs { } catch (Throwable t) { glint = false; } - return new ItemSpec(m.getKey().getKey(), b.data, b.blockEntity, glint); + BlockEntityState be = b.blockEntity; + // Head skins / banner patterns live on the stack, not the material (only those kinds pay for the meta). + if (be != null && ("player".equals(be.headType()) || be.kind() == BlockEntityState.Kind.BANNER)) { + be = BlockEntitySnapshotBuilder.withItemMeta(be, stack.getItemMeta()); + } + return new ItemSpec(m.getKey().getKey(), b.data, be, glint); } private static BlockInfo blockInfo(Material m) { diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/MapPixels.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/MapPixels.java new file mode 100644 index 0000000..7076c1e --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/MapPixels.java @@ -0,0 +1,81 @@ +package eu.mhsl.minecraft.pixelpics.render.snapshot; + +import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer; +import eu.mhsl.minecraft.pixelpics.utils.MapColorPalette; +import eu.mhsl.minecraft.pixelpics.utils.MapManager; +import java.lang.reflect.Field; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.MapMeta; +import org.bukkit.map.MapRenderer; +import org.bukkit.map.MapView; + +/** + * Captures a filled map's 128x128 colours for rendering it in an item frame. PixelPics photo maps come from + * their {@link ImageMapRenderer} (or the on-disk index cache), other maps from the server's map data + * ({@code MapItemSavedData.colors}, reflectively — Bukkit has no API to read it). MUST run on the main thread. + */ +public final class MapPixels { + + private static final int SIZE = ImageMapRenderer.MAP_SIZE; + + private MapPixels() {} + + /** ARGB {@code [y][x]}, or null when the stack is no map or has no map data (vanilla then draws the item). */ + public static int[][] of(ItemStack stack) { + try { + if (!(stack.getItemMeta() instanceof MapMeta meta) || !meta.hasMapView()) return null; + MapView view = meta.getMapView(); + if (view == null) return null; + byte[] colors = colors(view); + if (colors == null || colors.length != SIZE * SIZE) return null; + int[][] out = new int[SIZE][SIZE]; + for (int y = 0; y < SIZE; y++) { + for (int x = 0; x < SIZE; x++) out[y][x] = MapColorPalette.argb(colors[y * SIZE + x]); + } + return out; + } catch (Throwable t) { + return null; + } + } + + private static byte[] colors(MapView view) throws ReflectiveOperationException { + for (MapRenderer r : view.getRenderers()) { + if (r instanceof ImageMapRenderer image) { + byte[] indices = image.getIndices(); + return indices != null ? indices : film(); + } + } + if (MapManager.isManaged(view.getId())) { + byte[] cached = MapManager.readIndices(view.getId()); + if (cached != null) return cached; + } + return worldMapColors(view); + } + + /** A photo still being rendered shows blank film, as its map does. */ + private static byte[] film() { + byte[] b = new byte[SIZE * SIZE]; + java.util.Arrays.fill(b, ImageMapRenderer.filmColor()); + return b; + } + + private static volatile Field worldMapField, colorsField; + + /** CraftMapView#worldMap → MapItemSavedData#colors (what the vanilla CraftMapRenderer sends). */ + private static byte[] worldMapColors(MapView view) throws ReflectiveOperationException { + Field wm = worldMapField; + if (wm == null || wm.getDeclaringClass() != view.getClass()) { + wm = view.getClass().getDeclaredField("worldMap"); + wm.setAccessible(true); + worldMapField = wm; + } + Object data = wm.get(view); + if (data == null) return null; + Field c = colorsField; + if (c == null || c.getDeclaringClass() != data.getClass()) { + c = data.getClass().getField("colors"); + colorsField = c; + } + return (byte[]) c.get(data); + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/ImageMapRenderer.java b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/ImageMapRenderer.java index e6fa3ff..f705a7a 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/ImageMapRenderer.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/ImageMapRenderer.java @@ -109,6 +109,11 @@ public class ImageMapRenderer extends MapRenderer { return Math.min(0.999, noise * 0.7 + sweep * 0.3); } + /** The blank-film map colour a photo shows while it is still being rendered. */ + public static byte filmColor() { + return film(); + } + private static byte film() { if (!filmResolved) { filmIndex = MapColorPalette.mapIndex(MapColorPalette.nearestPos(206, 202, 190)); diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapColorPalette.java b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapColorPalette.java index 6d0aae2..55b7333 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapColorPalette.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapColorPalette.java @@ -17,6 +17,7 @@ public final class MapColorPalette { private static byte[] indices; private static int[] rgb; private static double[][] lab; + private static int[] argbByIndex; // all 256 map colour bytes → ARGB (transparent slots alpha 0) private MapColorPalette() {} @@ -48,9 +49,24 @@ public final class MapColorPalette { rgb[i] = rgbs.get(i); lab[i] = labs.get(i); } + argbByIndex = new int[256]; + for (int i = 0; i < 256; i++) { + try { + Color c = MapPalette.getColor((byte) i); + argbByIndex[i] = c.getAlpha() < 255 ? 0 : (0xFF << 24) | (c.getRGB() & 0xFFFFFF); + } catch (Throwable t) { + argbByIndex[i] = 0; + } + } initialized = true; } + /** ARGB of a raw map colour byte as the client draws it; transparent slots are 0. */ + public static int argb(byte mapColor) { + ensure(); + return argbByIndex[mapColor & 0xFF]; + } + public static int size() { ensure(); return indices.length; diff --git a/tools/BlockEntityTestRender.java b/tools/BlockEntityTestRender.java index 354844a..61024d1 100644 --- a/tools/BlockEntityTestRender.java +++ b/tools/BlockEntityTestRender.java @@ -71,7 +71,8 @@ public class BlockEntityTestRender { log.info("font glyphs loaded: " + (font.isEmpty() ? "NONE" : "ok")); CemBaker baker = new CemBaker(geo, textures, skins); beBaker = new BlockEntityBaker(geo, textures, skins, font); - decoBaker = new DecorationBaker(textures); + decoBaker = new DecorationBaker(textures, registry, + new eu.mhsl.minecraft.pixelpics.render.entity.cem.ItemGeometry(reader, registry, textures, geo, beBaker)); DefaultScreenRenderer renderer = new DefaultScreenRenderer(registry, tint, textures, baker, beBaker, log); BlockData air = (BlockData) Proxy.newProxyInstance(BlockEntityTestRender.class.getClassLoader(), @@ -204,17 +205,17 @@ public class BlockEntityTestRender { LinkedHashMap> decoScenes = new LinkedHashMap<>(); // kebab is 1x1; place a thin quad facing south spanning the block at z=0..0.0625. decoScenes.put("painting_kebab_S", List.of(new DecorationState(DecorationState.Kind.PAINTING, - 0, 0, 0, 1, 1, 0.0625, DecorationState.Facing.SOUTH, "kebab", null, 0, false))); + 0, 0, 0, 1, 1, 0.0625, DecorationState.Facing.SOUTH, "kebab", null, null, 0, false, false))); decoScenes.put("painting_kebab_N", List.of(new DecorationState(DecorationState.Kind.PAINTING, - 0, 0, 0.9375, 1, 1, 1, DecorationState.Facing.NORTH, "kebab", null, 0, false))); + 0, 0, 0.9375, 1, 1, 1, DecorationState.Facing.NORTH, "kebab", null, null, 0, false, false))); decoScenes.put("painting_pool_S", List.of(new DecorationState(DecorationState.Kind.PAINTING, - 0, 0, 0, 2, 1, 0.0625, DecorationState.Facing.SOUTH, "pool", null, 0, false))); + 0, 0, 0, 2, 1, 0.0625, DecorationState.Facing.SOUTH, "pool", null, null, 0, false, false))); decoScenes.put("item_frame", List.of(new DecorationState(DecorationState.Kind.ITEM_FRAME, - 0.0625, 0.0625, 0, 0.9375, 0.9375, 0.0625, DecorationState.Facing.SOUTH, null, "diamond", 0, false))); + 0.0625, 0.0625, 0, 0.9375, 0.9375, 0.0625, DecorationState.Facing.SOUTH, null, new eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec("diamond", null, null, false), null, 0, false, false))); decoScenes.put("item_frame_empty", List.of(new DecorationState(DecorationState.Kind.ITEM_FRAME, - 0.0625, 0.0625, 0, 0.9375, 0.9375, 0.0625, DecorationState.Facing.SOUTH, null, null, 0, false))); + 0.0625, 0.0625, 0, 0.9375, 0.9375, 0.0625, DecorationState.Facing.SOUTH, null, null, null, 0, false, false))); decoScenes.put("item_frame_E", List.of(new DecorationState(DecorationState.Kind.ITEM_FRAME, - 0.9375, 0.0625, 0.0625, 1.0, 0.9375, 0.9375, DecorationState.Facing.EAST, null, "diamond", 0, false))); + 0.9375, 0.0625, 0.0625, 1.0, 0.9375, 0.9375, DecorationState.Facing.EAST, null, new eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec("diamond", null, null, false), null, 0, false, false))); List cells = new ArrayList<>(); List names = new ArrayList<>(scenes.keySet()); diff --git a/tools/edgecase-plugin/pptest/PPTest.java b/tools/edgecase-plugin/pptest/PPTest.java index d501a15..d7e0f70 100644 --- a/tools/edgecase-plugin/pptest/PPTest.java +++ b/tools/edgecase-plugin/pptest/PPTest.java @@ -13,6 +13,7 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.*; import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; import org.bukkit.block.data.BlockData; import org.bukkit.block.data.type.Bed; import org.bukkit.command.Command; @@ -128,6 +129,13 @@ public class PPTest extends JavaPlugin { // Needs a datapack cat variant "pptest:ocelotcat" (asset_id entity/cat/ocelot) in the world's datapacks. scenes.put("rf_dpcat", new Scene("rf_dpcat", this::rfDatapackCat, 1.2, 1.4, -2.2, 1.2, 0.2, 0.5)); scenes.put("rf_armor", new Scene("rf_armor", this::rfArmor, 7, 3.2, -6.5, 7, 1.0, 1.5)); + // Item frames (vanilla ItemFrameRenderer): north wall with rotations 0-7, block/flat/tool/block-entity + // items, photo + synthetic + vanilla-data maps, invisible and glow frames; frames on all 6 block sides. + scenes.put("frames", new Scene("frames", this::frames, 4.5, 2.8, -3.5, 4.5, 2.6, 4)); + scenes.put("frames_close", new Scene("frames_close", this::frames, 2.6, 2.4, 0.8, 2.6, 2.3, 4)); + scenes.put("frames_side", new Scene("frames_side", this::frames, -1.5, 3.2, -1.0, 4.5, 2.6, 3.6)); + scenes.put("frames_dirs_ne", new Scene("frames_dirs_ne", this::framesDirs, 16, 7.5, -4, 8, 4, 3.5)); + scenes.put("frames_dirs_sw", new Scene("frames_dirs_sw", this::framesDirs, 0, 1.2, 10, 8, 4, 3.5)); } @Override @@ -165,7 +173,7 @@ public class PPTest extends JavaPlugin { Location eye = o.clone().add(sc.camX, sc.camY, sc.camZ); Vector look = o.clone().add(sc.lookX, sc.lookY, sc.lookZ).toVector().subtract(eye.toVector()); eye.setDirection(look); - RenderJob job = r.prepare(eye, sc.name.startsWith("proj_") || sc.name.startsWith("rf_") ? new Resolution(960, 640) : new Resolution(480, 320), null); + RenderJob job = r.prepare(eye, sc.name.startsWith("proj_") || sc.name.startsWith("rf_") || sc.name.startsWith("frames") ? new Resolution(960, 640) : new Resolution(480, 320), null); if (sc.name.equals("dbg")) { try { java.lang.reflect.Field f = DefaultScreenRenderer.class.getDeclaredField("objectBaker"); @@ -1098,4 +1106,133 @@ public class PPTest extends JavaPlugin { b.setBlock(Bukkit.createBlockData("minecraft:" + blocks[i])); } } + + // --- item frames ------------------------------------------------------------------------------- + + private void frames(Location o) { + for (int x = -2; x <= 11; x++) + for (int y = 0; y <= 5; y++) w.getBlockAt(o.getBlockX() + x, o.getBlockY() + y, o.getBlockZ() + 4).setType(Material.STONE, false); + BlockFace f = BlockFace.NORTH; + for (int r = 0; r < 8; r++) frame(o, r, 4, 3, f, false, it(Material.DIAMOND_SWORD), r, true); + ItemStack banner = it(Material.RED_BANNER); + org.bukkit.inventory.meta.BannerMeta bm = (org.bukkit.inventory.meta.BannerMeta) banner.getItemMeta(); + bm.addPattern(new org.bukkit.block.banner.Pattern(DyeColor.YELLOW, org.bukkit.block.banner.PatternType.STRIPE_TOP)); + banner.setItemMeta(bm); + ItemStack[] row3 = {it(Material.STONE), it(Material.OAK_STAIRS), it(Material.GRASS_BLOCK), it(Material.APPLE), + it(Material.TORCH), it(Material.CHEST), banner, eu.mhsl.minecraft.pixelpics.survival.CameraItems.createCamera(3), + it(Material.CREEPER_HEAD), it(Material.SHIELD)}; + for (int i = 0; i < row3.length; i++) frame(o, i - 1, 3, 3, f, false, row3[i], 0, true); + ItemStack photo = photoMap(), synth = synthMap(), vmap = vanillaMap(); + int[] photoRot = {0, 1, 2, 3, 5}; + for (int i = 0; i < photoRot.length; i++) frame(o, i - 1, 2, 3, f, false, photo, photoRot[i], true); + frame(o, 4, 2, 3, f, false, synth, 0, true); + frame(o, 5, 2, 3, f, false, synth, 1, true); + frame(o, 6, 2, 3, f, false, vmap, 0, true); + frame(o, 7, 2, 3, f, false, it(Material.MAP), 0, true); + frame(o, 8, 2, 3, f, false, it(Material.FILLED_MAP), 0, true); + ItemStack[] inv = {it(Material.DIAMOND_SWORD), it(Material.STONE), synth, it(Material.CHEST)}; + for (int i = 0; i < inv.length; i++) frame(o, i - 1, 1, 3, f, false, inv[i], i == 0 ? 1 : 0, false); + ItemStack[] glow = {it(Material.APPLE), it(Material.STONE), synth, null}; + for (int i = 0; i < glow.length; i++) frame(o, i + 3, 1, 3, f, true, glow[i], 0, true); + frame(o, 7, 1, 3, f, false, null, 0, true); + frame(o, 8, 1, 3, f, true, null, 0, true); + } + + /** Three stone blocks with a frame on every side: block item, tool, synthetic map (all at rotation 1). */ + private void framesDirs(Location o) { + ItemStack[] items = {it(Material.OAK_STAIRS), it(Material.DIAMOND_SWORD), synthMap()}; + BlockFace[] faces = {BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST, BlockFace.UP, BlockFace.DOWN}; + for (int i = 0; i < items.length; i++) { + int bx = 3 + i * 5, by = 4, bz = 3; + w.getBlockAt(o.getBlockX() + bx, o.getBlockY() + by, o.getBlockZ() + bz).setType(Material.STONE, false); + for (BlockFace face : faces) + frame(o, bx + face.getModX(), by + face.getModY(), bz + face.getModZ(), face, i == 2, items[i], 1, true); + } + } + + private ItemFrame frame(Location o, int x, int y, int z, BlockFace face, boolean glow, ItemStack item, int rot, boolean visible) { + Location l = o.clone().add(x + 0.5, y + 0.5, z + 0.5); + Class c = glow ? GlowItemFrame.class : ItemFrame.class; + ItemFrame fr = w.spawn(l, c, e -> { + e.setFacingDirection(face, true); + e.setPersistent(false); + }); + if (item != null) fr.setItem(item, false); + fr.setRotation(Rotation.values()[rot]); + fr.setVisible(visible); + fr.setFixed(true); + return fr; + } + + private static ItemStack mapItem(org.bukkit.map.MapView view) { + ItemStack s = it(Material.FILLED_MAP); + org.bukkit.inventory.meta.MapMeta mm = (org.bukkit.inventory.meta.MapMeta) s.getItemMeta(); + mm.setMapView(view); + s.setItemMeta(mm); + return s; + } + + /** A real stored PixelPics photo (lowest managed map id on disk), or the synthetic map if none exists. */ + private ItemStack photoMap() { + File dir = new File(Main.getInstance().getDataFolder(), "images"); + int best = Integer.MAX_VALUE; + File[] files = dir.listFiles(); + if (files != null) + for (File f : files) { + java.util.regex.Matcher m = java.util.regex.Pattern.compile("mapId_(\\d+)\\.mcmap").matcher(f.getName()); + if (m.matches()) best = Math.min(best, Integer.parseInt(m.group(1))); + } + int id = best == Integer.MAX_VALUE ? -1 : best; + @SuppressWarnings("deprecation") org.bukkit.map.MapView v = id < 0 ? null : Bukkit.getMap(id); + if (v == null) return synthMap(); + if (v.getRenderers().stream().noneMatch(r -> r instanceof eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer)) { + byte[] idx = eu.mhsl.minecraft.pixelpics.utils.MapManager.readIndices(id); + if (idx != null) eu.mhsl.minecraft.pixelpics.utils.MapManager.attachView(v, new eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer(idx)); + } + getLogger().info("frames: photo map id " + id); + return mapItem(v); + } + + private ItemStack synthMap; + + /** Asymmetric test picture as a PixelPics map: red top-left, blue top-right, green bottom-left, black "F". */ + private ItemStack synthMap() { + if (synthMap != null) return synthMap; + BufferedImage img = new BufferedImage(128, 128, BufferedImage.TYPE_INT_RGB); + java.awt.Graphics2D g = img.createGraphics(); + g.setColor(java.awt.Color.WHITE); + g.fillRect(0, 0, 128, 128); + g.setColor(java.awt.Color.RED); + g.fillRect(0, 0, 32, 32); + g.setColor(java.awt.Color.BLUE); + g.fillRect(96, 0, 32, 32); + g.setColor(new java.awt.Color(0, 160, 0)); + g.fillRect(0, 96, 32, 32); + g.setColor(java.awt.Color.BLACK); + g.fillRect(48, 24, 14, 80); + g.fillRect(48, 24, 44, 14); + g.fillRect(48, 56, 32, 12); + g.dispose(); + org.bukkit.map.MapView v = Bukkit.createMap(w); + eu.mhsl.minecraft.pixelpics.utils.MapManager.attachView(v, new eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer(img)); + return synthMap = mapItem(v); + } + + /** A plain vanilla map whose server-side colours are written directly (diagonal colour bands + transparent corner). */ + private ItemStack vanillaMap() { + org.bukkit.map.MapView v = Bukkit.createMap(w); + try { + java.lang.reflect.Field wm = v.getClass().getDeclaredField("worldMap"); + wm.setAccessible(true); + Object data = wm.get(v); + byte[] colors = (byte[]) data.getClass().getField("colors").get(data); + byte[] palette = {(byte) (18 * 4 + 2), (byte) (7 * 4 + 2), (byte) (12 * 4 + 1), (byte) (29 * 4 + 2)}; + for (int y = 0; y < 128; y++) + for (int x = 0; x < 128; x++) + colors[y * 128 + x] = x < 24 && y < 24 ? 0 : palette[((x + y) / 32) % palette.length]; + } catch (ReflectiveOperationException e) { + getLogger().warning("vanillaMap: " + e); + } + return mapItem(v); + } }