render all block entities from the dumped vanilla models with decompiled renderer poses, drop the cem block-entity path

This commit is contained in:
MineTec committed 2026-11-04 15:13:55 +01:00
1 parent c66ebaa942
commit 022b080fce
14 files changed
+659 -567

No files matched your search

@@ -79,7 +79,7 @@ public final class BlockModelRegistry {
int avg = fallbackColor(lastFlat);
// Block-entities (chests, signs, banners, beds, heads, …) use builtin/entity models with no
// Block-entities (chests, banners, heads, shulker boxes, …) use builtin/entity models with no
// geometry; their real shape is rendered separately through the entity scene. Return empty
// geometry so the ray passes through to that geometry instead of drawing a grey fallback cube.
if (isBlockEntityRendered(material)) {
@@ -120,7 +120,6 @@ public final class BlockModelRegistry {
String n = m.name();
boolean match = n.endsWith("_SIGN") // signs: standing/wall/(wall_)hanging
|| n.endsWith("_BANNER") // banners: standing/wall
|| n.endsWith("_BED")
|| n.endsWith("SHULKER_BOX") // SHULKER_BOX + <color>_SHULKER_BOX
|| ((n.endsWith("_SKULL") || n.endsWith("_HEAD")) && m != Material.PISTON_HEAD)
|| m == Material.CHEST
@@ -1,146 +1,240 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* Maps a {@link BlockEntityState} to its bundled CEM model name and ordered texture-path candidates.
* Block-entity geometry ships in the same CEM set as mobs (chest/sign/banner/…); textures live under
* {@code assets/minecraft/textures/entity/}. Mirrors the role {@link EntityModels} plays for mobs.
* Maps a {@link BlockEntityState} to the models its vanilla BlockEntityRenderer draws (from the client
* dump, see {@code tools/VanillaModelDump.java}) and to the renderer's PoseStack. Every pose below is
* transcribed from the decompiled 26.3 client renderers (ChestRenderer, SkullBlockRenderer,
* BannerRenderer, ShulkerBoxRenderer, DecoratedPotRenderer, BellRenderer, ConduitRenderer,
* LecternRenderer, Standing/HangingSignRenderer) — no calibration offsets. Mirrors the role
* {@link EntityModels} plays for mobs.
*
* <p>Beds and sign boards are plain block models since 26.x (no renderer model): the block-model
* pipeline draws them; only the sign TEXT is added here.
*/
public final class BlockEntityModels {
private BlockEntityModels() {}
/** The CEM ({@code .jem}) model name for a block-entity. */
public static String cemModel(BlockEntityState s) {
return switch (s.kind()) {
case CHEST, TRAPPED_CHEST, ENDER_CHEST, COPPER_CHEST -> switch (chestKind(s)) {
case LEFT -> "chest_left";
case RIGHT -> "chest_right";
case SINGLE -> "chest";
};
case SIGN -> "sign";
case WALL_SIGN -> "wall_sign";
case HANGING_SIGN -> "hanging_sign";
case BANNER, WALL_BANNER -> "banner";
case BED -> "bed";
case SHULKER_BOX -> "shulker_box";
case CONDUIT -> "conduit";
case DECORATED_POT -> "decorated_pot";
case BELL -> "bell";
case HEAD, WALL_HEAD -> headModel(s.headType());
};
/** How a part's texture is produced. */
public enum Paint {
/** First loadable texture of {@link Part#textures}. */
PLAIN,
/** Banner flag: {@code banner_base}, the base-colour {@code base} mask, then each pattern (BannerRenderer). */
BANNER_FLAG,
/** Player head: the owner's skin, else {@link Part#textures} (default Steve). */
SKIN
}
/**
* A block-entity model from the vanilla client dump ({@code layer/<layer>#<part>}, see
* {@code tools/VanillaModelDump.java}) plus the vanilla BlockEntityRenderer pose: {@code pose} maps
* Java model space (already scaled to blocks) into the block cell, origin at the cell's min corner —
* exactly the PoseStack the renderer sets up before {@code submitModel}.
* One {@code submitModel}/{@code submitModelPart} call: a dump model ({@code layer/<model>#<layer>}
* in rest pose, or {@code posed/…} posed by its setupAnim), its texture candidates and the top-level
* parts it leaves out (pot sides each carry their own sherd texture).
*/
public record VanillaModel(String layer, Affine pose) {}
/**
* The vanilla model + renderer pose for a block-entity, or null when the type still uses the CEM
* template path. To migrate another type, add its case here (layer name from the dump + the pose its
* vanilla renderer applies); the baker needs no further changes.
*/
public static VanillaModel vanillaModel(BlockEntityState s) {
// ChestRenderer: rotationAround(Y, -facing.toYRot(), 0.5, 0, 0.5); no Y flip (model y is up).
return switch (s.kind()) {
case CHEST, TRAPPED_CHEST, ENDER_CHEST, COPPER_CHEST -> new VanillaModel(
switch (chestKind(s)) {
case LEFT -> "layer/double_chest_left#main";
case RIGHT -> "layer/double_chest_right#main";
case SINGLE -> "layer/chest#main";
},
rotationAroundY(-s.facingDeg(), 0.5, 0.5));
default -> null;
};
}
/** JOML {@code rotationAround}: a Y rotation (deg) about the vertical line through (cx, ·, cz). */
private static Affine rotationAroundY(double deg, double cx, double cz) {
return Affine.translation(cx, 0, cz)
.mul(Affine.rotY(Math.toRadians(deg)))
.mul(Affine.translation(-cx, 0, -cz));
}
private static String headModel(String headType) {
if (headType == null) return "head";
return switch (headType) {
case "dragon" -> "head_dragon";
case "piglin" -> "head_piglin";
case "player" -> "head_player";
case "wither_skeleton" -> "wither_skull";
default -> "head"; // skeleton, zombie, creeper share the plain humanoid skull box
};
}
/** Ordered texture-path candidates; the baker uses the first that loads. */
public static List<ResourceLocation> textureCandidates(BlockEntityState s) {
List<String> paths = new ArrayList<>();
switch (s.kind()) {
case CHEST -> chestTextures(paths, s, "normal");
case TRAPPED_CHEST -> chestTextures(paths, s, "trapped");
case ENDER_CHEST -> paths.add("entity/chest/ender");
case COPPER_CHEST -> chestTextures(paths, s, s.colorName() == null ? "copper" : "copper_" + s.colorName());
case SIGN, WALL_SIGN -> {
String wood = s.wood() == null ? "oak" : s.wood();
paths.add("entity/signs/" + wood);
paths.add("entity/sign"); // legacy single-texture fallback
}
case HANGING_SIGN -> {
String wood = s.wood() == null ? "oak" : s.wood();
paths.add("entity/signs/hanging/" + wood);
}
case BANNER, WALL_BANNER -> paths.add("entity/banner/banner_base"); // tinted by baseColorArgb
case BED -> {
String c = s.colorName() == null ? "red" : s.colorName();
paths.add("entity/bed/" + c);
}
case SHULKER_BOX -> {
if (s.colorName() != null) paths.add("entity/shulker/shulker_" + s.colorName());
paths.add("entity/shulker/shulker"); // uncoloured (purpur) default
}
case CONDUIT -> paths.add("entity/conduit/base");
case DECORATED_POT -> paths.add("entity/decorated_pot/decorated_pot_base");
case BELL -> paths.add("entity/bell/bell_body");
case HEAD, WALL_HEAD -> headTextures(paths, s.headType());
public record Part(String model, List<String> textures, Set<String> hidden, Paint paint) {
static Part of(String model, String texture) {
return new Part(model, List.of(texture), Set.of(), Paint.PLAIN);
}
List<ResourceLocation> out = new ArrayList<>(paths.size());
for (String p : paths) out.add(ResourceLocation.parse(p));
return out;
}
/**
* The renderer's models plus its block pose: {@code pose} maps Java model space (already scaled to
* blocks) into the block cell, origin at the cell's min corner — exactly the PoseStack the renderer
* sets up before submitting. Items and worn heads use the parts WITHOUT this pose (the special item
* renderers submit the bare model; the item model's own transformation places it).
*/
public record VanillaModel(Affine pose, List<Part> parts) {}
/** The vanilla model + pose of a block-entity, or null when nothing model-based is drawn (signs). */
public static VanillaModel vanillaModel(BlockEntityState s) {
double deg = s.facingDeg();
return switch (s.kind()) {
// ChestRenderer: rotationAround(Y, -facing.toYRot(), 0.5, 0, 0.5); model y is up (no flip).
case CHEST, TRAPPED_CHEST, ENDER_CHEST, COPPER_CHEST -> {
String layer =
switch (chestKind(s)) {
case LEFT -> "layer/double_chest_left#main";
case RIGHT -> "layer/double_chest_right#main";
case SINGLE -> "layer/chest#main";
};
yield new VanillaModel(
rotationAround(Affine.rotY(Math.toRadians(-deg)), 0.5, 0, 0.5),
List.of(new Part(layer, chestTextures(s), Set.of(), Paint.PLAIN)));
}
// SkullBlockRenderer ground: T(0.5,0,0.5)·rotY(-segment·22.5)·S(-1,-1,1); wall: T(0.5 - step·0.25,
// 0.25, …)·rotY(-opposite.toYRot())·S(-1,-1,1).
case HEAD -> new VanillaModel(
Affine.translation(0.5, 0, 0.5)
.mul(Affine.rotY(Math.toRadians(-deg)))
.mul(Affine.scale(-1, -1, 1)),
List.of(headPart(s.headType())));
case WALL_HEAD -> {
double sx = -Math.sin(Math.toRadians(deg)), sz = Math.cos(Math.toRadians(deg)); // facing step
yield new VanillaModel(
Affine.translation(0.5 - sx * 0.25, 0.25, 0.5 - sz * 0.25)
.mul(Affine.rotY(Math.toRadians(-(deg + 180))))
.mul(Affine.scale(-1, -1, 1)),
List.of(headPart(s.headType())));
}
// BannerRenderer: T(0.5,0,0.5)·rotY(-angle)·S(2/3,-2/3,-2/3), angle = rotation or wall facing; the
// pole/bar use banner_base, the flag (wave phase 0) gets base colour + patterns.
case BANNER, WALL_BANNER -> {
String model = s.kind() == BlockEntityState.Kind.BANNER ? "standing_banner" : "wall_banner";
double k = 2 / 3.0;
yield new VanillaModel(
Affine.translation(0.5, 0, 0.5)
.mul(Affine.rotY(Math.toRadians(-deg)))
.mul(Affine.scale(k, -k, -k)),
List.of(
Part.of("layer/" + model + "#main", "entity/banner/banner_base"),
new Part(
"posed/" + model + "#flag",
List.of("entity/banner/banner_base"),
Set.of(),
Paint.BANNER_FLAG)));
}
// ShulkerBoxRenderer: T(.5,.5,.5)·S(0.9995)·direction.getRotation()·S(1,-1,-1)·T(0,-1,0); lid closed.
case SHULKER_BOX -> new VanillaModel(
Affine.translation(0.5, 0.5, 0.5)
.mul(Affine.scale(0.9995))
.mul(directionRotation(s.direction()))
.mul(Affine.scale(1, -1, -1))
.mul(Affine.translation(0, -1, 0)),
List.of(new Part(
"layer/shulker_box#main",
s.colorName() == null
? List.of("entity/shulker/shulker")
: List.of("entity/shulker/shulker_" + s.colorName(), "entity/shulker/shulker"),
Set.of(),
Paint.PLAIN)));
// DecoratedPotRenderer: rotateAround(Y, 180 - facing.toYRot(), .5,.5,.5); neck/top/bottom from the
// base sheet, each side its sherd pattern (or the blank side).
case DECORATED_POT -> new VanillaModel(
rotationAround(Affine.rotY(Math.toRadians(180 - deg)), 0.5, 0.5, 0.5), potParts(s));
// BellRenderer: the model as is (pivot 8,12,8 in block px); the frame is the block model.
case BELL -> new VanillaModel(
Affine.identity(), List.of(Part.of("layer/bell#main", "entity/bell/bell_body")));
// ConduitRenderer: inactive → shell at T(.5,.5,.5); active → cage at T(.5, 0.3 + hh·0.2, .5) with
// hh at animation time 0 (= 0.75), unrotated. Wind and the camera-facing eye are not drawn.
case CONDUIT -> s.active()
? new VanillaModel(
Affine.translation(0.5, 0.3 + 0.75 * 0.2, 0.5),
List.of(Part.of("layer/conduit#cage", "entity/conduit/cage")))
: new VanillaModel(
Affine.translation(0.5, 0.5, 0.5),
List.of(Part.of("layer/conduit#shell", "entity/conduit/base")));
// LecternRenderer (only with a book): T(.5,1.0625,.5)·rotY(-clockwise(facing).toYRot())·rotZ(67.5°)·
// T(0,-0.125,0), book open as BOOK_STATE (dumped posed).
case LECTERN -> s.active()
? new VanillaModel(
Affine.translation(0.5, 1.0625, 0.5)
.mul(Affine.rotY(Math.toRadians(-(deg + 90))))
.mul(Affine.rotZ(Math.toRadians(67.5)))
.mul(Affine.translation(0, -0.125, 0)),
List.of(Part.of("posed/book#main", "entity/enchantment/enchanting_table_book")))
: null;
case SIGN, WALL_SIGN, HANGING_SIGN -> null;
};
}
/**
* The sign renderer's text transformation (Standing/HangingSignRenderer.textTransformation) WITHOUT
* its final {@code scale(s, -s, s)}: returns the frame whose origin is the text anchor (centre of the
* four lines), x right / y up / +z towards the reader, and the font-pixel size {@code s} in blocks via
* {@code out[0]}. Back text is the front transform turned by 180°.
*/
public static Affine signTextFrame(BlockEntityState s, boolean front, double[] pxSize) {
double deg = s.facingDeg();
Affine m;
if (s.kind() == BlockEntityState.Kind.HANGING_SIGN) {
m = Affine.translation(0.5, 0.9375, 0.5)
.mul(Affine.rotY(Math.toRadians(-deg)))
.mul(Affine.translation(0, -0.3125, 0));
if (!front) m = m.mul(Affine.rotY(Math.PI));
m = m.mul(Affine.translation(0, -0.32, 0.073));
pxSize[0] = 0.0140625;
} else {
m = Affine.translation(0.5, 0.5, 0.5).mul(Affine.rotY(Math.toRadians(-deg)));
if (s.kind() == BlockEntityState.Kind.WALL_SIGN) m = m.mul(Affine.translation(0, -0.3125, -0.4375));
if (!front) m = m.mul(Affine.rotY(Math.PI));
m = m.mul(Affine.translation(0, 0.33333334, 0.046666667));
pxSize[0] = 0.010416667;
}
return m;
}
private static Part headPart(String headType) {
String t = headType == null ? "skeleton" : headType;
return switch (t) {
case "wither_skeleton" -> Part.of("layer/wither_skeleton_skull#main", "entity/skeleton/wither_skeleton");
case "zombie" -> Part.of("layer/zombie_head#main", "entity/zombie/zombie");
case "creeper" -> Part.of("layer/creeper_head#main", "entity/creeper/creeper");
case "dragon" -> Part.of("posed/dragon_skull#main", "entity/enderdragon/dragon");
case "piglin" -> Part.of("posed/piglin_head#main", "entity/piglin/piglin");
case "player" -> new Part(
"layer/player_head#main", List.of("entity/player/wide/steve"), Set.of(), Paint.SKIN);
default -> Part.of("layer/skeleton_skull#main", "entity/skeleton/skeleton");
};
}
private static final List<String> POT_SIDES = List.of("front", "left", "right", "back"); // = sherd order
private static List<Part> potParts(BlockEntityState s) {
List<Part> parts = new ArrayList<>(5);
parts.add(Part.of("layer/decorated_pot_base#main", "entity/decorated_pot/decorated_pot_base"));
for (int i = 0; i < POT_SIDES.size(); i++) {
List<String> tex = new ArrayList<>(2);
String sherd = i < s.sherds().size() ? s.sherds().get(i) : null;
if (sherd != null && sherd.endsWith("_pottery_sherd")) {
tex.add("entity/decorated_pot/" + sherd.replace("_pottery_sherd", "_pottery_pattern"));
}
tex.add("entity/decorated_pot/decorated_pot_side");
List<String> others = new ArrayList<>(POT_SIDES);
others.remove(i);
parts.add(new Part("layer/decorated_pot_sides#main", tex, Set.copyOf(others), Paint.PLAIN));
}
return parts;
}
private static BlockEntityState.ChestKind chestKind(BlockEntityState s) {
return s.chestKind() == null ? BlockEntityState.ChestKind.SINGLE : s.chestKind();
}
private static void chestTextures(List<String> paths, BlockEntityState s, String base) {
switch (chestKind(s)) {
case LEFT -> paths.add("entity/chest/" + base + "_left");
case RIGHT -> paths.add("entity/chest/" + base + "_right");
case SINGLE -> paths.add("entity/chest/" + base);
}
paths.add("entity/chest/" + base); // fallback to the single texture
private static List<String> chestTextures(BlockEntityState s) {
String base =
switch (s.kind()) {
case TRAPPED_CHEST -> "trapped";
case ENDER_CHEST -> "ender";
case COPPER_CHEST -> s.colorName() == null ? "copper" : "copper_" + s.colorName();
default -> "normal";
};
if (s.kind() == BlockEntityState.Kind.ENDER_CHEST) return List.of("entity/chest/ender");
return switch (chestKind(s)) {
case LEFT -> List.of("entity/chest/" + base + "_left", "entity/chest/" + base);
case RIGHT -> List.of("entity/chest/" + base + "_right", "entity/chest/" + base);
case SINGLE -> List.of("entity/chest/" + base);
};
}
private static void headTextures(List<String> paths, String headType) {
if (headType == null) {
paths.add("entity/skeleton/skeleton");
return;
}
switch (headType) {
case "wither_skeleton" -> paths.add("entity/skeleton/wither_skeleton");
case "zombie" -> paths.add("entity/zombie/zombie");
case "creeper" -> paths.add("entity/creeper/creeper");
case "piglin" -> paths.add("entity/piglin/piglin");
case "dragon" -> paths.add("entity/enderdragon/dragon");
case "player" -> paths.add("entity/player/wide/steve"); // skin handled separately by the baker
default -> paths.add("entity/skeleton/skeleton");
}
/** JOML {@code rotationAround}: {@code T(c) · rot · T(-c)}. */
private static Affine rotationAround(Affine rot, double cx, double cy, double cz) {
return Affine.translation(cx, cy, cz).mul(rot).mul(Affine.translation(-cx, -cy, -cz));
}
/** Minecraft {@code Direction.getRotation()} (JOML rotationXYZ = Rx·Ry·Rz); null/unknown = UP. */
private static Affine directionRotation(String direction) {
double h = Math.PI / 2;
if (direction == null) return Affine.identity();
return switch (direction) {
case "down" -> Affine.rotX(Math.PI);
case "north" -> Affine.rotX(h).mul(Affine.rotZ(Math.PI));
case "south" -> Affine.rotX(h);
case "west" -> Affine.rotX(h).mul(Affine.rotZ(h));
case "east" -> Affine.rotX(h).mul(Affine.rotZ(-h));
default -> Affine.identity();
};
}
}
@@ -4,12 +4,13 @@ import java.util.List;
/**
* Immutable snapshot of one block-entity captured on the main thread, sufficient to bake and place it
* off-thread. Block-entities (chests, signs, banners, beds, heads, …) use vanilla {@code builtin/entity}
* block models with no geometry; their real shape lives in the bundled CEM models, so they are rendered
* through the same baking/intersection path as mobs. Bukkit-free so the baker needs no world access.
* off-thread. Block-entities drawn by a vanilla BlockEntityRenderer (chests, banners, heads, shulker
* boxes, …) have no block-model geometry; their shape is the renderer's entity model (dumped from the
* client, see {@link BlockEntityModels}), rendered through the same baking/intersection path as mobs.
* Bukkit-free so the baker needs no world access.
*
* <p>{@code facingDeg} is the world yaw the model should face (already converted from the block's
* facing/rotation, vanilla convention). Type-specific fields are null/0/empty when unused.
* <p>{@code facingDeg} is the vanilla yaw of the block's facing/rotation (0 = south, 90 = west; a
* 16-step rotation is {@code segment · 22.5}). Type-specific fields are null/0/false/empty when unused.
*/
public record BlockEntityState(
Kind kind,
@@ -17,16 +18,15 @@ public record BlockEntityState(
int by,
int bz,
float facingDeg,
String direction, // 6-way facing ("up", "north", …) of shulker boxes; null otherwise
boolean active, // lectern: has a book; conduit: active
ChestKind chestKind, // double-chest half (CHEST/TRAPPED_CHEST/COPPER_CHEST)
int baseColorArgb, // banner base tint (white texture); 0 = none
String colorName, // bed/shulker/banner colour ("red", …) or copper-chest stage ("exposed", …); null = default
String wood, // sign/hanging-sign wood ("oak", "spruce", …); null = default
BedPart bedPart, // bed half
int baseColorArgb, // banner base colour; 0 = none
String colorName, // shulker colour ("red", …) or copper-chest stage ("exposed", …); null = default
String headType, // "skeleton","wither_skeleton","zombie","creeper","piglin","dragon","player"
String skinUrl, // player-head owner skin URL; null otherwise
List<BannerPattern> patterns, // banner overlay patterns (may be empty)
List<String> sherds, // decorated-pot sherds: front/back/left/right item keys (may be empty)
BellAttach bellAttach, // bell attachment; null when not a bell
List<String> sherds, // decorated-pot sherds: front/left/right/back item keys (may be empty)
SignText frontText, // sign front-side text; null when not a sign or blank
SignText backText // sign back-side text; null when not a sign or blank
) {
@@ -40,13 +40,13 @@ public record BlockEntityState(
HANGING_SIGN,
BANNER,
WALL_BANNER,
BED,
SHULKER_BOX,
HEAD,
WALL_HEAD,
CONDUIT,
DECORATED_POT,
BELL
BELL,
LECTERN
}
public enum ChestKind {
@@ -55,18 +55,6 @@ public record BlockEntityState(
RIGHT
}
public enum BedPart {
HEAD,
FOOT
}
public enum BellAttach {
FLOOR,
CEILING,
SINGLE_WALL,
DOUBLE_WALL
}
/** One banner overlay layer: a pattern key (e.g. "stripe_top") and the ARGB colour it is dyed with. */
public record BannerPattern(String patternKey, int colorArgb) {}
@@ -14,38 +14,24 @@ import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube;
import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity;
import eu.mhsl.minecraft.pixelpics.render.entity.TextureOps;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Bakes a {@link BlockEntityState} into world-space cubes using the bundled CEM models, reusing
* {@link CemGeometry}. Unlike mobs, block-entities are not dropped to the ground by their model bounds;
* each type is placed by an affine that reproduces the vanilla BlockEntityRenderer: the block cell
* centre, a yaw from the block's facing/rotation, and a per-type local offset/scale.
*
* <p>The CEM block-entity models are authored centred on X/Z with their base near Y=0 (after the px→
* block scale), so the placement is {@code T(cell centre) · rotY(yaw) · T(localOffset)} and most types
* only need defaults. Wall-mounted and scaled types (signs, wall heads, beds) override via {@link Place}.
*
* <p>Types mapped in {@link BlockEntityModels#vanillaModel} (currently chests) instead use the model
* dumped from the vanilla client, placed by the vanilla renderer's own pose (see {@code bakeVanilla});
* the CEM template path above stays as the fallback for everything else.
* Bakes a {@link BlockEntityState} into world-space cubes: the models its vanilla BlockEntityRenderer
* draws (dumped from the client, see {@link BlockEntityModels#vanillaModel}), placed by that renderer's
* own pose — {@code T(cell min corner) · pose · S(1/16) · CEM_TO_JAVA} — so position, rotation and box-UV
* match vanilla by construction (no per-type calibration). Signs add their text quads in the renderer's
* text frame (the board itself is a block model).
*/
public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
private static final double SIGN_SCALE = 0.6666667; // vanilla SignRenderer model scale
// Sign-text placement (model px in the board's local frame; calibrated against the test harness).
// The text block is scaled to fill the board's writable area (W×H) so few-line signs are rendered as
// large as possible (legibility on low-res maps), capped so a single line isn't blown up absurdly.
private static final double SIGN_TEXT_W = 22.0, SIGN_TEXT_H = 11.0, SIGN_TEXT_CAP = 0.5; // board 24x12
private static final double HANG_TEXT_W = 13.0, HANG_TEXT_H = 9.0, HANG_TEXT_CAP = 0.42; // board 14x10
private static final double SIGN_TEXT_CY = 20.0; // board Y∈[14,26], centre 20
private static final double HANGING_TEXT_CY = 5.0; // board Y∈[0,10], centre 5
private static final double BOARD_FRONT_Z = 1.0; // both boards: front/back faces at Z=±1
private static final double TEXT_Z_EPS = 0.05; // lift the text just off the board face
private static final double TEXT_THICK = 0.5; // slab thickness so the ray test never degenerates
// Sign text is scaled to fill the board's writable area (W×H, font px of the renderer's text frame) so
// few-line signs render as large as possible (legibility on low-res maps), capped so a single line isn't
// blown up absurdly. Vanilla: 1 font px per text px, lines 90 (hanging 60) px wide, 10 (9) px apart.
private static final double SIGN_TEXT_W = 88, SIGN_TEXT_H = 44, SIGN_TEXT_CAP = 2.0;
private static final double HANG_TEXT_W = 58, HANG_TEXT_H = 40, HANG_TEXT_CAP = 1.87;
private static final double TEXT_THICK =
0.2; // slab behind the text plane (text px) so the ray test never degenerates
private final CemModelLoader models;
private final TextureCache textures;
@@ -64,120 +50,117 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
this.font = font;
}
/** Returns the baked block-entity, or null when it has no model/texture (then nothing renders). */
/** The placed block-entity in its cell, or null when nothing renders (unmapped/missing assets). */
@Override
public RenderedEntity bake(BlockEntityState s) {
List<Layer> layers = layers(s);
RenderedEntity vanilla = bakeVanilla(s, layers);
if (vanilla != null) return vanilla;
// Head model depends on the texture aspect (skeleton 64x32 vs zombie/player 64x64), so resolve it
// from the chosen texture rather than statically.
CemModelLoader.CemModel model = layers.isEmpty() ? null : models.get(modelName(s, layers.getFirst().tex));
// Since 26.x signs (and beds) are real block models (textures under block/, no entity/signs/): the
// board then comes from the block-model pipeline and only the sign TEXT is added here.
boolean signKind = s.kind() == BlockEntityState.Kind.SIGN
|| s.kind() == BlockEntityState.Kind.WALL_SIGN
|| s.kind() == BlockEntityState.Kind.HANGING_SIGN;
if (model == null && !signKind) return null;
Place p = place(s);
Affine pre = Affine.scale(p.scale / 16.0);
Affine placement = Affine.translation(s.bx() + 0.5, s.by(), s.bz() + 0.5)
.mul(Affine.rotY(Math.toRadians(p.yaw)))
.mul(Affine.translation(p.lx, p.ly, p.lz));
List<EntityCube> cubes = new ArrayList<>();
if (model != null)
for (Layer layer : layers) {
for (CemGeometry.Baked b : CemGeometry.bakeModel(
model, layer.tex, pre, layer.hidden, layer.texW, layer.texH, layer.boxUv)) {
cubes.add(new EntityCube(b.from(), b.to(), b.faces(), placement.mul(b.world())));
}
}
addSignText(s, placement.mul(pre), cubes);
Affine cell = Affine.translation(s.bx(), s.by(), s.bz());
BlockEntityModels.VanillaModel vm = BlockEntityModels.vanillaModel(s);
if (vm != null) bakeParts(s, vm, cell.mul(vm.pose()), cubes);
addSignText(s, cell, cubes);
return cubes.isEmpty() ? null : RenderedEntity.of(cubes);
}
/**
* Vanilla path: the model dumped from the client ({@link BlockEntityModels#vanillaModel}) placed by its
* renderer's pose, {@code T(cell min corner) · pose · S(1/16) · CEM_TO_JAVA}. Position, rotation and
* box-UV then match vanilla by construction (no per-type calibration offsets). Returns null when the
* type has no vanilla mapping or the dump lacks the layer, so the caller falls back to the CEM template.
* The block-entity's models WITHOUT the block pose, in Java model space (blocks) mapped by
* {@code toWorld}: what the special item renderers and the worn-skull layer submit (the item model's
* transformation / the head frame place it).
*/
private RenderedEntity bakeVanilla(BlockEntityState s, List<Layer> layers) {
public void bakeModel(BlockEntityState s, Affine toWorld, List<EntityCube> out) {
BlockEntityModels.VanillaModel vm = BlockEntityModels.vanillaModel(s);
if (vm == null || layers.isEmpty()) return null;
CemModelLoader.CemModel model = models.vanilla(vm.layer());
if (model == null) return null;
Affine pre = Affine.translation(s.bx(), s.by(), s.bz())
.mul(vm.pose())
.mul(Affine.scale(1 / 16.0))
.mul(CemGeometry.CEM_TO_JAVA);
List<EntityCube> cubes = new ArrayList<>();
for (Layer layer : layers) {
for (CemGeometry.Baked b :
CemGeometry.bakeModel(model, layer.tex, pre, layer.hidden, layer.texW, layer.texH, layer.boxUv)) {
cubes.add(new EntityCube(b.from(), b.to(), b.faces(), b.world()));
if (vm != null) bakeParts(s, vm, toWorld, out);
}
private void bakeParts(BlockEntityState s, BlockEntityModels.VanillaModel vm, Affine pose, List<EntityCube> out) {
Affine pre = pose.mul(Affine.scale(1 / 16.0)).mul(CemGeometry.CEM_TO_JAVA);
for (BlockEntityModels.Part part : vm.parts()) {
CemModelLoader.CemModel model = models.vanilla(part.model());
int[][] tex = model == null ? null : texture(s, part);
if (tex == null) continue;
for (CemGeometry.Baked b : CemGeometry.bakeModel(model, tex, pre, part.hidden())) {
out.add(new EntityCube(b.from(), b.to(), b.faces(), b.world()));
}
}
return cubes.isEmpty() ? null : RenderedEntity.of(cubes);
}
// --- textures ---
private int[][] texture(BlockEntityState s, BlockEntityModels.Part part) {
if (part.paint() == BlockEntityModels.Paint.SKIN && s.skinUrl() != null) {
int[][] skin = skins.get(s.skinUrl()).orElse(null);
if (skin != null) return skin;
}
for (String path : part.textures()) {
int[][] t = textures.get(ResourceLocation.parse(path)).orElse(null);
if (t == null) continue;
return part.paint() == BlockEntityModels.Paint.BANNER_FLAG ? bannerFlag(t, s) : t;
}
return null;
}
/**
* Appends flat text quads in front of (and behind) the sign board, rasterized from the captured
* {@link BlockEntityState.SignText}. Built in the board's model-px frame and transformed by the same
* {@code placement·pre} as the CEM board, so the text rides exactly on the board.
*
* <p>The CEM sign model faces north by default and is aimed with {@code rotY(180−facingDeg)} where
* {@code facingDeg} is the direction the sign's FRONT faces — so the model's −Z (NORTH) face ends up
* being the front the player reads, and +Z (SOUTH) is the back. Each face uses an unflipped UV: the
* intersector already mirrors NORTH relative to SOUTH ({@code s=1−fx} vs {@code s=fx}), so both read
* left-to-right when viewed from outside.
* BannerRenderer flag: {@code banner_base}, then the {@code base} mask dyed with the base colour, then
* each pattern mask ({@code entity/banner/<key>}) dyed with its colour, composited into one texture
* (one geometry, no coplanar layers).
*/
private void addSignText(BlockEntityState s, Affine toWorld, List<EntityCube> cubes) {
private int[][] bannerFlag(int[][] base, BlockEntityState s) {
int[][] out = TextureOps.deepCopy(base);
if (s.baseColorArgb() != 0) overlayMask(out, "entity/banner/base", s.baseColorArgb());
for (BlockEntityState.BannerPattern pat : s.patterns()) {
overlayMask(out, "entity/banner/" + pat.patternKey(), pat.colorArgb());
}
return out;
}
private void overlayMask(int[][] out, String path, int argb) {
int[][] mask = textures.get(ResourceLocation.parse(path)).orElse(null);
if (mask == null || mask.length != out.length || mask[0].length != out[0].length) return;
int[][] dyed = TextureOps.deepCopy(mask);
TextureOps.tint(dyed, argb);
TextureOps.overlay(out, dyed);
}
// --- sign text ---
/**
* Front and back text as flat quads in the sign renderer's text frame
* ({@link BlockEntityModels#signTextFrame}): the text block is centred on the frame origin and faces
* +Z (the reader). The quad's +Z (SOUTH) face is unflipped: viewed from +Z the intersector reads it
* left to right ({@code s = fx}).
*/
private void addSignText(BlockEntityState s, Affine cell, List<EntityCube> cubes) {
if (font == null || font.isEmpty()) return;
boolean hanging = s.kind() == BlockEntityState.Kind.HANGING_SIGN;
boolean sign = hanging || s.kind() == BlockEntityState.Kind.SIGN || s.kind() == BlockEntityState.Kind.WALL_SIGN;
if (!sign) return;
double cy = hanging ? HANGING_TEXT_CY : SIGN_TEXT_CY;
double tw = hanging ? HANG_TEXT_W : SIGN_TEXT_W;
double th = hanging ? HANG_TEXT_H : SIGN_TEXT_H;
double cap = hanging ? HANG_TEXT_CAP : SIGN_TEXT_CAP;
addSide(s.frontText(), Direction.NORTH, tw, th, cap, cy, toWorld, cubes);
addSide(s.backText(), Direction.SOUTH, tw, th, cap, cy, toWorld, cubes);
BlockEntityState.Kind k = s.kind();
boolean hanging = k == BlockEntityState.Kind.HANGING_SIGN;
if (!hanging && k != BlockEntityState.Kind.SIGN && k != BlockEntityState.Kind.WALL_SIGN) return;
addSide(s, s.frontText(), true, hanging, cell, cubes);
addSide(s, s.backText(), false, hanging, cell, cubes);
}
private void addSide(
BlockEntityState s,
BlockEntityState.SignText t,
Direction faceDir,
double tw,
double th,
double cap,
double cy,
Affine toWorld,
boolean front,
boolean hanging,
Affine cell,
List<EntityCube> cubes) {
if (t == null) return;
int[][] bmp = SignTextRasterizer.rasterize(
trimBlankLines(t.lines()), font, t.fillArgb(), t.outlineArgb(), t.glowing());
if (bmp == null) return;
// Scale so the text block fills the board's writable area, whichever dimension binds, capped.
double tw = hanging ? HANG_TEXT_W : SIGN_TEXT_W;
double th = hanging ? HANG_TEXT_H : SIGN_TEXT_H;
double cap = hanging ? HANG_TEXT_CAP : SIGN_TEXT_CAP;
// Scale so the text block fills the writable area, whichever dimension binds, capped.
double fpm = Math.min(cap, Math.min(tw / bmp[0].length, th / bmp.length));
double blockW = bmp[0].length * fpm;
double blockH = bmp.length * fpm;
double z0, z1;
if (faceDir == Direction.NORTH) { // front: −Z face, text sits just past −1
z1 = -BOARD_FRONT_Z - TEXT_Z_EPS;
z0 = z1 - TEXT_THICK;
} else { // back: +Z face, text sits just past +1
z0 = BOARD_FRONT_Z + TEXT_Z_EPS;
z1 = z0 + TEXT_THICK;
}
double w = bmp[0].length * fpm, h = bmp.length * fpm;
double[] px = new double[1];
Affine frame = BlockEntityModels.signTextFrame(s, front, px);
Face[] faces = new Face[6];
faces[faceDir.ordinal()] = new Face(bmp, 0, 0, 1, 1, 0, -1);
double[] from = {-blockW / 2, cy - blockH / 2, z0};
double[] to = {blockW / 2, cy + blockH / 2, z1};
cubes.add(new EntityCube(from, to, faces, toWorld));
faces[Direction.SOUTH.ordinal()] = new Face(bmp, 0, 0, 1, 1, 0, -1);
double[] from = {-w / 2, -h / 2, -TEXT_THICK};
double[] to = {w / 2, h / 2, 0};
cubes.add(new EntityCube(from, to, faces, cell.mul(frame).mul(Affine.scale(px[0]))));
}
/** Drops leading/trailing blank lines so short text fills the board; keeps interior blanks. */
@@ -187,158 +170,4 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
while (hi > lo && lines.get(hi - 1).isBlank()) hi--;
return lines.subList(lo, hi);
}
/** The CEM model name; for heads it depends on the texture's aspect (64x32 vs square 64x64). */
private String modelName(BlockEntityState s, int[][] tex) {
if (s.kind() != BlockEntityState.Kind.HEAD && s.kind() != BlockEntityState.Kind.WALL_HEAD) {
return BlockEntityModels.cemModel(s);
}
String t = s.headType();
if ("dragon".equals(t)) return "head_dragon";
if ("piglin".equals(t)) return "head_piglin";
// Square textures (zombie, player) carry a hat overlay → use the 64x64 player-head model; the
// 2:1 skull textures (skeleton, wither_skeleton, creeper) use the 64x32 skull model.
boolean square = tex.length > 0 && tex.length == tex[0].length;
return square ? "head_player" : "head";
}
/** A single bake pass: one texture applied to the parts not in {@code hidden}, with optional UV size
* and a flag forcing box-UV (for standalone part textures not matching the model's per-face UV). */
private record Layer(int[][] tex, Set<String> hidden, int texW, int texH, boolean boxUv) {
Layer(int[][] tex, Set<String> hidden) {
this(tex, hidden, 0, 0, false);
}
}
/** Some types paint different parts with different textures (pot sherds, conduit cage/heart). */
private List<Layer> layers(BlockEntityState s) {
if (s.kind() == BlockEntityState.Kind.DECORATED_POT) {
return potLayers(s);
}
if (s.kind() == BlockEntityState.Kind.CONDUIT) {
return conduitLayers();
}
int[][] tex = resolveTexture(s);
return tex == null ? List.of() : List.of(new Layer(tex, hiddenParts(s)));
}
private static final Set<String> CONDUIT_PARTS = Set.of("eye", "cage", "base", "wind");
/** The conduit's cage and inner heart use separate textures; the eye/wind (active state) are skipped. */
private List<Layer> conduitLayers() {
int[][] cage =
textures.get(ResourceLocation.parse("entity/conduit/cage")).orElse(null);
int[][] base =
textures.get(ResourceLocation.parse("entity/conduit/base")).orElse(null);
// Each part's texture is authored at its own native size (32x16) with a box-UV layout, so force
// box-UV and normalise by the texture's own size (the model's per-face UV assumes a combined sheet).
List<Layer> layers = new ArrayList<>(2);
if (cage != null)
layers.add(new Layer(cage, onlyPart("cage", CONDUIT_PARTS), cage[0].length, cage.length, true));
if (base != null)
layers.add(new Layer(base, onlyPart("base", CONDUIT_PARTS), base[0].length, base.length, true));
return layers;
}
/** Hidden set that leaves only {@code keep} visible out of {@code all}. */
private static Set<String> onlyPart(String keep, Set<String> all) {
Set<String> hidden = new HashSet<>(all);
hidden.remove(keep);
return hidden;
}
private static final Set<String> POT_PARTS = Set.of("neck", "top", "bottom", "front", "back", "left", "right");
private static final String[] POT_FACES = {"front", "left", "right", "back"}; // matches sherd capture order
private List<Layer> potLayers(BlockEntityState s) {
int[][] base = textures.get(ResourceLocation.parse("entity/decorated_pot/decorated_pot_base"))
.orElse(null);
int[][] side = textures.get(ResourceLocation.parse("entity/decorated_pot/decorated_pot_side"))
.orElse(null);
if (base == null) return List.of();
List<Layer> layers = new ArrayList<>();
// Structure (rim/neck/foot) comes from the combined base texture; the four sides are NOT in it.
layers.add(new Layer(base, new HashSet<>(List.of("front", "back", "left", "right"))));
// Each side: its sherd pattern if set, else the plain brick side. The model's per-face UV maps the
// centre of the 16x16 texture onto the face (centred, edges intact).
for (int i = 0; i < POT_FACES.length; i++) {
int[][] tex = side;
if (i < s.sherds().size()) {
String sherd = s.sherds().get(i);
if (sherd != null && sherd.endsWith("_pottery_sherd")) {
int[][] pat = textures.get(ResourceLocation.parse(
"entity/decorated_pot/" + sherd.replace("_pottery_sherd", "_pottery_pattern")))
.orElse(null);
if (pat != null) tex = pat;
}
}
if (tex == null) continue;
layers.add(new Layer(tex, onlyPart(POT_FACES[i], POT_PARTS)));
}
return layers;
}
// --- placement parameters per type ---
/** Local placement: applied yaw (deg), model scale, and a local-frame offset (blocks). */
private record Place(double yaw, double scale, double lx, double ly, double lz) {}
private Place place(BlockEntityState s) {
double yaw = 180 - s.facingDeg(); // model default faces north; rotate by the block's facing
return switch (s.kind()) {
case SIGN -> new Place(yaw, SIGN_SCALE, 0, 0, 0);
case WALL_SIGN -> new Place(yaw, SIGN_SCALE, 0, -0.3125, 0.4375); // drop to mid-block, push to wall
case WALL_HEAD -> new Place(yaw, 1.0, 0, 0.25, 0.25); // mid-height, against the wall
case WALL_BANNER -> new Place(yaw, 1.0, 0, -0.16, 0.4375);
default -> new Place(yaw, 1.0, 0, 0, 0);
};
}
/** Parts to omit (the unused bed half / its legs, the conduit's open-state shell). */
private Set<String> hiddenParts(BlockEntityState s) {
return switch (s.kind()) {
case BED -> s.bedPart() == BlockEntityState.BedPart.HEAD
? Set.of("foot", "leg3", "leg4")
: Set.of("head", "leg1", "leg2");
case CONDUIT -> Set.of("eye", "wind");
default -> Set.of();
};
}
// --- texture resolution ---
private int[][] resolveTexture(BlockEntityState s) {
// Player heads use the owner's skin when available.
if (s.skinUrl() != null
&& (s.kind() == BlockEntityState.Kind.HEAD || s.kind() == BlockEntityState.Kind.WALL_HEAD)) {
int[][] skin = skins.get(s.skinUrl()).orElse(null);
if (skin != null) return skin;
}
boolean isBanner = s.kind() == BlockEntityState.Kind.BANNER || s.kind() == BlockEntityState.Kind.WALL_BANNER;
for (ResourceLocation rl : BlockEntityModels.textureCandidates(s)) {
int[][] t = textures.get(rl).orElse(null);
if (t == null) continue;
if (isBanner) return bakeBanner(t, s);
return t;
}
return null;
}
/**
* Composites a banner texture: tint the white {@code banner_base} cloth with the base colour, then
* alpha-overlay each pattern mask ({@code entity/banner/<key>}) dyed with its own colour, in order.
*/
private int[][] bakeBanner(int[][] base, BlockEntityState s) {
int[][] out = TextureOps.deepCopy(base);
if (s.baseColorArgb() != 0) TextureOps.tint(out, s.baseColorArgb());
for (BlockEntityState.BannerPattern pat : s.patterns()) {
int[][] mask = textures.get(ResourceLocation.parse("entity/banner/" + pat.patternKey()))
.orElse(null);
if (mask == null) continue;
int[][] dyed = TextureOps.deepCopy(mask);
TextureOps.tint(dyed, pat.colorArgb());
TextureOps.overlay(out, dyed);
}
return out;
}
}
@@ -274,10 +274,8 @@ public final class CemBaker implements EntityBaker<EntityState> {
if (head != null && h != null) {
Affine hj = h.mul(CemGeometry.JAVA_LOCAL);
if (head.blockEntity() != null && head.blockEntity().kind() == BlockEntityState.Kind.HEAD) {
items.bakeBlockEntity(
head.blockEntity(),
hj.mul(Affine.scale(1.1875, -1.1875, -1.1875)).mul(Affine.translation(-0.5, 0, -0.5)),
out);
// CustomHeadLayer: head frame · S(1.1875) · SkullBlockRenderer.submitSkull (bare skull model).
items.bakeBlockEntityModel(head.blockEntity(), hj.mul(Affine.scale(1.1875)), out);
} else {
// Snow golems wear their pumpkin a bit lower (SnowGolemHeadLayer) than CustomHeadLayer items.
double dy = s.typeKey().equals("snow_golem") ? -0.34375 : -0.25;
@@ -10,8 +10,8 @@ import java.util.Map;
import java.util.Set;
/**
* Shared, stateless CEM geometry baking used by both {@link CemBaker} (mobs) and the block-entity
* baker. It turns a {@link CemModelLoader.CemModel} into a list of {@link Baked} boxes (local cube +
* Shared, stateless CEM geometry baking used by {@link CemBaker} (mobs), the block-entity baker and
* item geometry. It turns a {@link CemModelLoader.CemModel} into a list of {@link Baked} boxes (local cube +
* its model→pre-space transform), faithfully reproducing the OptiFine/Blockbench CEM transform. The
* placement into the world (yaw, ground-snap, block cell) is the caller's responsibility.
*/
@@ -40,25 +40,7 @@ final class CemGeometry {
/** Bake all parts of a model with the given pre-transform; parts in {@code hidden} are skipped. */
static List<Baked> bakeModel(CemModelLoader.CemModel model, int[][] tex, Affine pre, Set<String> hidden) {
return bakeModel(model, tex, pre, hidden, 0, 0, false);
}
/**
* Bake all parts with an explicit texture size for UV normalisation ({@code texW}/{@code texH} of 0
* fall back to the model's declared size; use a real size when the applied texture differs from the
* model's declared {@code textureSize}, e.g. a 16x16 sherd on a 32x32-authored pot face). When
* {@code ignoreFaceUv} is set, box-UV is forced even if the model declares per-face UV — used for a
* standalone texture (e.g. the conduit cage) whose layout is box-UV, not the combined-sheet layout.
*/
static List<Baked> bakeModel(
CemModelLoader.CemModel model,
int[][] tex,
Affine pre,
Set<String> hidden,
int texW,
int texH,
boolean ignoreFaceUv) {
return bake(model, tex, pre, hidden, Set.of(), texW, texH, ignoreFaceUv, Map.of(), null);
return bake(model, tex, pre, hidden, Set.of(), Map.of(), null);
}
/**
@@ -75,7 +57,7 @@ final class CemGeometry {
Set<String> show,
Map<String, CemModelLoader.PartPose> overrides,
Map<String, Affine> frames) {
return bake(model, tex, pre, Set.of(), show, 0, 0, false, overrides, frames);
return bake(model, tex, pre, Set.of(), show, overrides, frames);
}
private static List<Baked> bake(
@@ -84,14 +66,9 @@ final class CemGeometry {
Affine pre,
Set<String> hidden,
Set<String> show,
int texW,
int texH,
boolean ignoreFaceUv,
Map<String, CemModelLoader.PartPose> overrides,
Map<String, Affine> frames) {
int nw = texW > 0 ? texW : model.texW();
int nh = texH > 0 ? texH : model.texH();
Ctx ctx = new Ctx(hidden, show, nw, nh, tex, ignoreFaceUv, new ArrayList<>(), overrides, frames);
Ctx ctx = new Ctx(hidden, show, model.texW(), model.texH(), tex, new ArrayList<>(), overrides, frames);
for (CemModelLoader.CemPart p : model.parts()) {
if (model.absolute()) {
bakeAbsolute(p, pre, ctx);
@@ -112,7 +89,6 @@ final class CemGeometry {
int texW,
int texH,
int[][] tex,
boolean ignoreFaceUv,
List<Baked> out,
Map<String, CemModelLoader.PartPose> overrides,
Map<String, Affine> frames) {}
@@ -173,8 +149,7 @@ final class CemGeometry {
double[] inf = b.inflate();
double[] from = {org[0] - inf[0], org[1] - inf[1], org[2] - inf[2]};
double[] to = {org[0] + b.size()[0] + inf[0], org[1] + b.size()[1] + inf[1], org[2] + b.size()[2] + inf[2]};
double[][] faceUv = ctx.ignoreFaceUv ? null : b.faceUv();
ModelCube mc = new ModelCube(org, b.size(), inf[0], b.uv(), b.mirror(), faceUv);
ModelCube mc = new ModelCube(org, b.size(), inf[0], b.uv(), b.mirror(), b.faceUv());
// Vanilla texScale stretches the UV normalisation (texture treated as texSize*scale).
int tw = ctx.texW, th = ctx.texH;
if (b.texScale() != null) {
@@ -71,7 +71,7 @@ public final class CemModelLoader {
private final Map<String, CemModel> models = new HashMap<>();
// Vanilla dump models live in their own namespace so they never shadow CEM-template names the
// block-entity path relies on (e.g. "wither_skull" head vs. projectile).
// legacy entity fallback relies on (the two sets use different names for different models).
private final Map<String, CemModel> vanilla = new HashMap<>();
private final Map<String, EntityInfo> entities = new HashMap<>();
@@ -120,14 +120,20 @@ public final class ItemGeometry {
}
}
/** A block-entity block (chest, head, banner, …) in the block cube [0,1]³ via the block-entity baker. */
/** A placed block-entity block (chest, head, banner, …) in the block cube [0,1]³ (block displays, …). */
public void bakeBlockEntity(BlockEntityState state, Affine toWorld, List<EntityCube> out) {
// Placed as in the world: cell (0,0,0) with the renderer's block pose.
if (state == null || blockEntities == null) return;
RenderedEntity r = blockEntities.bake(state); // placed at cell (bx,by,bz) = (0,0,0)
RenderedEntity r = blockEntities.bake(state);
if (r == null) return;
for (EntityCube c : r.cubes) out.add(new EntityCube(c.from, c.to, c.faces, toWorld.mul(c.toWorld)));
}
/** A block-entity's bare models (no block pose) in Java model space mapped by {@code toWorld}. */
public void bakeBlockEntityModel(BlockEntityState state, Affine toWorld, List<EntityCube> out) {
if (state != null && blockEntities != null) blockEntities.bakeModel(state, toWorld, out);
}
// --- item definition / model resolution --------------------------------------------------------
private Info info(String id, String context) {
@@ -346,8 +352,12 @@ public final class ItemGeometry {
}
}
default -> {
// Block-entity specials (chest, head, banner, shulker box, bed, conduit, pot, signs, …).
if (item.blockEntity() != null) bakeBlockEntity(item.blockEntity(), m, out);
// Block-entity specials (chest, head, banner, shulker box, conduit, pot, …): like the vanilla
// special renderers, the bare model (no block pose) placed by the item's transformation.
if (item.blockEntity() != null && blockEntities != null) {
Affine xf = info.specialTransform != null ? m.mul(info.specialTransform) : m;
blockEntities.bakeModel(item.blockEntity(), xf, out);
}
}
}
}
@@ -2,8 +2,6 @@ package eu.mhsl.minecraft.pixelpics.render.snapshot;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.BannerPattern;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.BedPart;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.BellAttach;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.ChestKind;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.Kind;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
@@ -19,6 +17,7 @@ import org.bukkit.World;
import org.bukkit.block.Banner;
import org.bukkit.block.BlockFace;
import org.bukkit.block.BlockState;
import org.bukkit.block.Conduit;
import org.bukkit.block.DecoratedPot;
import org.bukkit.block.Sign;
import org.bukkit.block.Skull;
@@ -26,16 +25,15 @@ import org.bukkit.block.banner.Pattern;
import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.Directional;
import org.bukkit.block.data.Rotatable;
import org.bukkit.block.data.type.Bed;
import org.bukkit.block.data.type.Bell;
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.profile.PlayerProfile;
import org.bukkit.util.Vector;
/**
* Captures block-entities (chests, signs, banners, beds, heads, …) near the view frustum into immutable
* Captures block-entities (chests, signs, banners, heads, pots, lecterns, …) near the view frustum into immutable
* {@link BlockEntityState}s. MUST run on the main thread (live {@link BlockState} access). Reads each
* loaded chunk's tile-entities rather than scanning every block, then keeps only those inside the
* camera's bounding box.
@@ -120,19 +118,12 @@ public final class BlockEntitySnapshotBuilder {
return b.build();
}
// --- beds ---
if (data instanceof Bed bed) {
BedPart part = bed.getPart() == Bed.Part.HEAD ? BedPart.HEAD : BedPart.FOOT;
return base(Kind.BED, bx, by, bz, faceToYaw(bed.getFacing()))
.bedPart(part)
.colorName(stripColor(n, "_BED"))
.build();
}
// --- shulker boxes ---
if (n.endsWith("SHULKER_BOX")) {
String color = n.equals("SHULKER_BOX") ? null : stripColor(n, "_SHULKER_BOX");
return base(Kind.SHULKER_BOX, bx, by, bz, facingYaw(data))
String dir = data instanceof Directional d ? d.getFacing().name().toLowerCase(Locale.ROOT) : "up";
return base(Kind.SHULKER_BOX, bx, by, bz, 0)
.direction(dir)
.colorName(color)
.build();
}
@@ -165,7 +156,6 @@ public final class BlockEntitySnapshotBuilder {
// --- signs ---
if (n.endsWith("_SIGN")) {
String wood = signWood(n);
Kind kind;
float yaw;
if (n.endsWith("_WALL_SIGN")) {
@@ -178,7 +168,7 @@ public final class BlockEntitySnapshotBuilder {
kind = Kind.SIGN;
yaw = rotationYaw(data);
}
Builder b = base(kind, bx, by, bz, yaw).wood(wood);
Builder b = base(kind, bx, by, bz, yaw);
if (ts instanceof Sign sign) {
b.frontText(signText(sign.getSide(Side.FRONT)));
b.backText(signText(sign.getSide(Side.BACK)));
@@ -200,7 +190,16 @@ public final class BlockEntitySnapshotBuilder {
// --- conduit ---
if (mat == Material.CONDUIT) {
return base(Kind.CONDUIT, bx, by, bz, 0).build();
return base(Kind.CONDUIT, bx, by, bz, 0)
.active(ts instanceof Conduit c && c.isActive())
.build();
}
// --- lectern (the block is a block model; the renderer only adds the open book) ---
if (data instanceof Lectern lectern) {
return base(Kind.LECTERN, bx, by, bz, facingYaw(data))
.active(lectern.hasBook())
.build();
}
// --- decorated pot ---
@@ -210,19 +209,9 @@ public final class BlockEntitySnapshotBuilder {
return b.build();
}
// --- bell ---
// --- bell (frame = block model; the renderer draws the bell body) ---
if (mat == Material.BELL) {
BellAttach attach = BellAttach.FLOOR;
if (data instanceof Bell bd) {
attach = switch (bd.getAttachment()) {
case FLOOR -> BellAttach.FLOOR;
case CEILING -> BellAttach.CEILING;
case SINGLE_WALL -> BellAttach.SINGLE_WALL;
case DOUBLE_WALL -> BellAttach.DOUBLE_WALL;};
}
return base(Kind.BELL, bx, by, bz, facingYaw(data))
.bellAttach(attach)
.build();
return base(Kind.BELL, bx, by, bz, 0).build();
}
return null;
@@ -295,17 +284,6 @@ public final class BlockEntitySnapshotBuilder {
lines, ColorUtil.signFillArgb(dye, glow), ColorUtil.signOutlineArgb(dye), glow);
}
private static String signWood(String name) {
String s = name;
for (String suf : new String[] {"_WALL_HANGING_SIGN", "_HANGING_SIGN", "_WALL_SIGN", "_SIGN"}) {
if (s.endsWith(suf)) {
s = s.substring(0, s.length() - suf.length());
break;
}
}
return s.toLowerCase(Locale.ROOT);
}
private static String headType(String name) {
String s = name.replace("_WALL_", "_");
return switch (s) {
@@ -320,7 +298,7 @@ public final class BlockEntitySnapshotBuilder {
}
private static List<String> sherds(DecoratedPot pot) {
// Order: front, left, right, back — matches the CEM decorated_pot face parts.
// Order: front, left, right, back — the DecoratedPotRenderer side parts (BlockEntityModels.potParts).
List<String> out = new ArrayList<>(4);
for (DecoratedPot.Side side : new DecoratedPot.Side[] {
DecoratedPot.Side.FRONT, DecoratedPot.Side.LEFT, DecoratedPot.Side.RIGHT, DecoratedPot.Side.BACK
@@ -349,7 +327,7 @@ public final class BlockEntitySnapshotBuilder {
return p.getPattern().getKey().getKey();
}
// --- small fluent builder to keep the 15-field record construction readable ---
// --- small fluent builder to keep the record construction readable ---
private static Builder base(Kind kind, int bx, int by, int bz, float yaw) {
return new Builder(kind, bx, by, bz, yaw);
@@ -359,16 +337,15 @@ public final class BlockEntitySnapshotBuilder {
private final Kind kind;
private final int bx, by, bz;
private final float yaw;
private String direction;
private boolean active;
private ChestKind chestKind;
private int baseColorArgb;
private String colorName;
private String wood;
private BedPart bedPart;
private String headType;
private String skinUrl;
private List<BannerPattern> patterns = List.of();
private List<String> sherds = List.of();
private BellAttach bellAttach;
private BlockEntityState.SignText frontText;
private BlockEntityState.SignText backText;
@@ -395,13 +372,13 @@ public final class BlockEntitySnapshotBuilder {
return this;
}
Builder wood(String v) {
this.wood = v;
Builder direction(String v) {
this.direction = v;
return this;
}
Builder bedPart(BedPart v) {
this.bedPart = v;
Builder active(boolean v) {
this.active = v;
return this;
}
@@ -425,11 +402,6 @@ public final class BlockEntitySnapshotBuilder {
return this;
}
Builder bellAttach(BellAttach v) {
this.bellAttach = v;
return this;
}
Builder frontText(BlockEntityState.SignText v) {
this.frontText = v;
return this;
@@ -447,16 +419,15 @@ public final class BlockEntitySnapshotBuilder {
by,
bz,
yaw,
direction,
active,
chestKind,
baseColorArgb,
colorName,
wood,
bedPart,
headType,
skinUrl,
patterns,
sherds,
bellAttach,
frontText,
backText);
}
@@ -1 +1 @@
{"version":"26.3","models":{"acacia_boat":{"model":"{\"textureSize\":[128,64],\"absolute\":true,\"layer\":\"boat/acacia#main\",\"models\":[{\"part\":\"root\",\"pivot\":[0.0,24.0,0.0],\"rotate\":[0.0,0.0,0.0],\"submodels\":[{\"part\":\"right_paddle\",\"pivot\":[-3.0,29.0,-9.0],\"rotate\":[37.5,-142.1347,11.25],\"boxes\":[{\"coordinates\":[-4.0,27.0,-14.0,2.0,2.0,18.0],\"textureOffset\":[62.0,20.0]},{\"coordinates\":[-4.001,26.0,-1.0,1.0,6.0,7.0],\"textureOffset\":[62.0,20.0]}]},{\"part\":\"left_paddle\",\"pivot\":[-3.0,29.0,9.0],\"rotate\":[37.5,-37.8653,11.25],\"boxes\":[{\"coordinates\":[-4.0,27.0,4.0,2.0,2.0,18.0],\"textureOffset\":[62.0,0.0]},{\"coordinates\":[-2.999,26.0,17.0,1.0,6.0,7.0],\"textureOffset\":[62.0,0.0]}]},{\"part\":\"left\",\"pivot\":[0.0,20.0,9.0],\"rotate\":[0.0,0.0,0.0],\"boxes\":[{\"coordinates\":[-14.0,21.0,8.0,28.0,6.0,2.0],\"textureOffset\":[0.0,43.0]}]},{\"part\":\"bottom\",\"pivot\":[0.0,21.0,1.0],\"rotate\":[-90.0,0.0,0.0],\"boxes\":[{\"coordinates\":[-14.0,14.0,-2.0,28.0,16.0,3.0],\"textureOffset\":[0.0,0.0]}]},{\"part\":\"back\",\"pivot\":[15.0,20.0,4.0],\"rotate\":[0.0,-270.0,0.0],\"boxes\":[{\"coordinates\":[10.0,21.0,3.0,18.0,6.0,2.0],\"textureOffset\":[0.0,19.0]}]},{\"part\":\"front\",\"pivot\":[-15.0,20.0,0.0],\"rotate\":[0.0,-90.0,0.0],\"boxes\":[{\"coordinates\":[-23.0,21.0,-1.0,16.0,6.0,2.0],\"textureOffset\":[0.0,27.0]}]},{\"part\":\"right\",\"pivot\":[0.0,20.0,-9.0],\"rotate\":[0.0,-180.0,0.0],\"boxes\":[{\"coordinates\":[-14.0,21.0,-10.0,28.0,6.0,2.0],\"textureOffset\":[0.0,35.0]}]}]}]}"},"acacia_boat/waterPatchModel":{"model":"{\"textureSize\":[0,0],\"absolute\":true,\"layer\":\"boat#water_patch\",\"models\":[{\"part\":\"root\",\"pivot\":[0.0,24.0,0.0],\"rotate\":[0.0,0.0,0.0],\"submodels\":[{\"part\":\"water_patch\",\"pivot\":[0.0,27.0,1.0],\"rotate\":[-90.0,0.0,0.0],\"boxes\":[{\"coordinates\":[-14.0,20.0,-2.0,28.0,16.0,3.0],\"textureOffset\":[0.0,0.0]}]}]}]}"},"acacia_chest_boat":{"model":"{\"textureSize\":[128,128],\"absolute\":true,\"layer\":\"chest_boat/acacia#main\",\"models\":[{\"part\":\"root\",\"pivot\":[0.0,24.0,0.0],\"rotate\":[0.0,0.0,0.0],\"submodels\":[{\"part\":\"chest_bottom\",\"pivot\":[2.0,29.0,-6.0],\"rotate\":[0.0,90.0,0.0],\"boxes\":[{\"coordinates\":[-10.0,21.0,-6.0,12.0,8.0,12.0],\"textureOffset\":[0.0,76.0]}]},{\"part\":\"right_paddle\",\"pivot\":[-3.0,29.0,-9.0],\"rotate\":[37.5,-142.1347,11.25],\"boxes\":[{\"coordinates\":[-4.0,27.0,-14.0,2.0,2.0,18.0],\"textureOffset\":[62.0,20.0]},{\"coordinates\":[-4.001,26.0,-1.0,1.0,6.0,7.0],\"textureOffset\":[62.0,20.0]}]},{\"part\":\"left_paddle\",\"pivot\":[-3.0,29.0,9.0],\"rotate\":[37.5,-37.8653,11.25],\"boxes\":[{\"coordinates\":[-4.0,27.0,4.0,2.0,2.0,18.0],\"textureOffset\":[62.0,0.0]},{\"coordinates\":[-2.999,26.0,17.0,1.0,6.0,7.0],\"textureOffset\":[62.0,0.0]}]},{\"part\":\"left\",\"pivot\":[0.0,20.0,9.0],\"rotate\":[0.0,0.0,0.0],\"boxes\":[{\"coordinates\":[-14.0,21.0,8.0,28.0,6.0,2.0],\"textureOffset\":[0.0,43.0]}]},{\"part\":\"bottom\",\"pivot\":[0.0,21.0,1.0],\"rotate\":[-90.0,0.0,0.0],\"boxes\":[{\"coordinates\":[-14.0,14.0,-2.0,28.0,16.0,3.0],\"textureOffset\":[0.0,0.0]}]},{\"part\":\"back\",\"pivot\":[15.0,20.0,4.0],\"rotate\":[0.0,-270.0,0.0],\"boxes\":[{\"coordinates\":[10.0,21.0,3.0,18.0,6.0,2.0],\"textureOffset\":[0.0,19.0]}]},{\"part\":\"chest_lid\",\"pivot\":[2.0,33.0,-6.0],\"rotate\":[0.0,90.0,0.0],\"boxes\":[{\"coordinates\":[-10.0,29.0,-6.0,12.0,4.0,12.0],\"textureOffset\":[0.0,59.0]}]},{\"part\":\"front\",\"pivot\":[-15.0,20.0,0.0],\"rotate\":[0.0,-90.0,0.0],\"boxes\":[{\"coordinates\":[-23.0,21.0,-1.0,16.0,6.0,2.0],\"textureOffset\":[0.0,27.0]}]},{\"part\":\"right\",\"pivot\":[0.0,20.0,-9.0],\"rotate\":[0.0,-180.0,0.0],\"boxes\":[{\"coordinates\":[-14.0,21.0,-10.0,28.0,6.0,2.0],\"textureOffset\":[0.0,35.0]}]},{\"part\":\"chest_lock\",\"pivot\":[1.0,30.0,-1.0],\"rotate\":[0.0,90.0,0.0],\"boxes\":[{\"coordinates\":[-1.0,26.0,-1.0,2.0,4.0,1.0],\"textureOffset\":[0.0,59.0]}]}]}]}"},"acacia_chest_boat/waterPatchModel":{"model":"{\"textureSize\":[0,0],\"absolute\":true,\"layer\":\"boat#water_patch\",\"models\":[{\"part\":\"root\",\"pivot\":[0.0,24.0,0.0],\"rotate\":[0.0,0.0,0.0],\"submodels\":[{\"part\":\"water_patch\",\"pivot\":[0.0,27.0,1.0],\"rotate\":[-90.0,0.0,0.0],\"boxes\":[{\"coordinates\":[-14.0,20.0,-2.0,28.0,16.0,3.0],\"textureOffset\":[0.0,0.0]}]}]}]}"},"allay":{"model":"{\"textureSize\":[32,32],\"absolute\":true,\"layer\":\"allay#main\",\"models\":[{\"part\":\"root\",\"pivot\":[0.0,0.25,0.0],\"rotate\":[0.0,0.0,0.0],\"submodels\":[{\"part\":\"head\",\"pivot\":[0.0,4.24,0.0],\"rotate\":[0.0,0.0,0.0],\"boxes\":[{\"coordinates\":[-2.5,4.24,-2.5,5.0,5.0,5.0],\"textureOffset\":[0.0,0.0]}]},{\"part\":\"body\",\"pivot\":[0.0,4.25,0.0],\"rotate\":[0.0,0.0,0.0],\"boxes\":[{\"coordinates\":[-1.5,0.25,-1.0,3.0,4.0,2.0],\"textureOffset\":[0.0,10.0]},{\"coordinates\":[-1.5,-0.75,-1.0,3.0,5.0,2.0],\"textureOffset\":[0.0,16.0],\"sizeAdd\":-0.2}],\"submodels\":[{\"part\":\"right_arLine truncated
{"version":"26.3","models":{"acacia_boat":{"model":"{\"textureSize\":[128,64],\"absolute\":true,\"layer\":\"boat/acacia#main\",\"models\":[{\"part\":\"root\",\"pivot\":[0.0,24.0,0.0],\"rotate\":[0.0,0.0,0.0],\"submodels\":[{\"part\":\"right_paddle\",\"pivot\":[-3.0,29.0,-9.0],\"rotate\":[37.5,-142.1347,11.25],\"boxes\":[{\"coordinates\":[-4.0,27.0,-14.0,2.0,2.0,18.0],\"textureOffset\":[62.0,20.0]},{\"coordinates\":[-4.001,26.0,-1.0,1.0,6.0,7.0],\"textureOffset\":[62.0,20.0]}]},{\"part\":\"left_paddle\",\"pivot\":[-3.0,29.0,9.0],\"rotate\":[37.5,-37.8653,11.25],\"boxes\":[{\"coordinates\":[-4.0,27.0,4.0,2.0,2.0,18.0],\"textureOffset\":[62.0,0.0]},{\"coordinates\":[-2.999,26.0,17.0,1.0,6.0,7.0],\"textureOffset\":[62.0,0.0]}]},{\"part\":\"left\",\"pivot\":[0.0,20.0,9.0],\"rotate\":[0.0,0.0,0.0],\"boxes\":[{\"coordinates\":[-14.0,21.0,8.0,28.0,6.0,2.0],\"textureOffset\":[0.0,43.0]}]},{\"part\":\"bottom\",\"pivot\":[0.0,21.0,1.0],\"rotate\":[-90.0,0.0,0.0],\"boxes\":[{\"coordinates\":[-14.0,14.0,-2.0,28.0,16.0,3.0],\"textureOffset\":[0.0,0.0]}]},{\"part\":\"back\",\"pivot\":[15.0,20.0,4.0],\"rotate\":[0.0,-270.0,0.0],\"boxes\":[{\"coordinates\":[10.0,21.0,3.0,18.0,6.0,2.0],\"textureOffset\":[0.0,19.0]}]},{\"part\":\"front\",\"pivot\":[-15.0,20.0,0.0],\"rotate\":[0.0,-90.0,0.0],\"boxes\":[{\"coordinates\":[-23.0,21.0,-1.0,16.0,6.0,2.0],\"textureOffset\":[0.0,27.0]}]},{\"part\":\"right\",\"pivot\":[0.0,20.0,-9.0],\"rotate\":[0.0,-180.0,0.0],\"boxes\":[{\"coordinates\":[-14.0,21.0,-10.0,28.0,6.0,2.0],\"textureOffset\":[0.0,35.0]}]}]}]}"},"acacia_boat/waterPatchModel":{"model":"{\"textureSize\":[0,0],\"absolute\":true,\"layer\":\"boat#water_patch\",\"models\":[{\"part\":\"root\",\"pivot\":[0.0,24.0,0.0],\"rotate\":[0.0,0.0,0.0],\"submodels\":[{\"part\":\"water_patch\",\"pivot\":[0.0,27.0,1.0],\"rotate\":[-90.0,0.0,0.0],\"boxes\":[{\"coordinates\":[-14.0,20.0,-2.0,28.0,16.0,3.0],\"textureOffset\":[0.0,0.0]}]}]}]}"},"acacia_chest_boat":{"model":"{\"textureSize\":[128,128],\"absolute\":true,\"layer\":\"chest_boat/acacia#main\",\"models\":[{\"part\":\"root\",\"pivot\":[0.0,24.0,0.0],\"rotate\":[0.0,0.0,0.0],\"submodels\":[{\"part\":\"chest_bottom\",\"pivot\":[2.0,29.0,-6.0],\"rotate\":[0.0,90.0,0.0],\"boxes\":[{\"coordinates\":[-10.0,21.0,-6.0,12.0,8.0,12.0],\"textureOffset\":[0.0,76.0]}]},{\"part\":\"right_paddle\",\"pivot\":[-3.0,29.0,-9.0],\"rotate\":[37.5,-142.1347,11.25],\"boxes\":[{\"coordinates\":[-4.0,27.0,-14.0,2.0,2.0,18.0],\"textureOffset\":[62.0,20.0]},{\"coordinates\":[-4.001,26.0,-1.0,1.0,6.0,7.0],\"textureOffset\":[62.0,20.0]}]},{\"part\":\"left_paddle\",\"pivot\":[-3.0,29.0,9.0],\"rotate\":[37.5,-37.8653,11.25],\"boxes\":[{\"coordinates\":[-4.0,27.0,4.0,2.0,2.0,18.0],\"textureOffset\":[62.0,0.0]},{\"coordinates\":[-2.999,26.0,17.0,1.0,6.0,7.0],\"textureOffset\":[62.0,0.0]}]},{\"part\":\"left\",\"pivot\":[0.0,20.0,9.0],\"rotate\":[0.0,0.0,0.0],\"boxes\":[{\"coordinates\":[-14.0,21.0,8.0,28.0,6.0,2.0],\"textureOffset\":[0.0,43.0]}]},{\"part\":\"bottom\",\"pivot\":[0.0,21.0,1.0],\"rotate\":[-90.0,0.0,0.0],\"boxes\":[{\"coordinates\":[-14.0,14.0,-2.0,28.0,16.0,3.0],\"textureOffset\":[0.0,0.0]}]},{\"part\":\"back\",\"pivot\":[15.0,20.0,4.0],\"rotate\":[0.0,-270.0,0.0],\"boxes\":[{\"coordinates\":[10.0,21.0,3.0,18.0,6.0,2.0],\"textureOffset\":[0.0,19.0]}]},{\"part\":\"chest_lid\",\"pivot\":[2.0,33.0,-6.0],\"rotate\":[0.0,90.0,0.0],\"boxes\":[{\"coordinates\":[-10.0,29.0,-6.0,12.0,4.0,12.0],\"textureOffset\":[0.0,59.0]}]},{\"part\":\"front\",\"pivot\":[-15.0,20.0,0.0],\"rotate\":[0.0,-90.0,0.0],\"boxes\":[{\"coordinates\":[-23.0,21.0,-1.0,16.0,6.0,2.0],\"textureOffset\":[0.0,27.0]}]},{\"part\":\"right\",\"pivot\":[0.0,20.0,-9.0],\"rotate\":[0.0,-180.0,0.0],\"boxes\":[{\"coordinates\":[-14.0,21.0,-10.0,28.0,6.0,2.0],\"textureOffset\":[0.0,35.0]}]},{\"part\":\"chest_lock\",\"pivot\":[1.0,30.0,-1.0],\"rotate\":[0.0,90.0,0.0],\"boxes\":[{\"coordinates\":[-1.0,26.0,-1.0,2.0,4.0,1.0],\"textureOffset\":[0.0,59.0]}]}]}]}"},"acacia_chest_boat/waterPatchModel":{"model":"{\"textureSize\":[0,0],\"absolute\":true,\"layer\":\"boat#water_patch\",\"models\":[{\"part\":\"root\",\"pivot\":[0.0,24.0,0.0],\"rotate\":[0.0,0.0,0.0],\"submodels\":[{\"part\":\"water_patch\",\"pivot\":[0.0,27.0,1.0],\"rotate\":[-90.0,0.0,0.0],\"boxes\":[{\"coordinates\":[-14.0,20.0,-2.0,28.0,16.0,3.0],\"textureOffset\":[0.0,0.0]}]}]}]}"},"allay":{"model":"{\"textureSize\":[32,32],\"absolute\":true,\"layer\":\"allay#main\",\"models\":[{\"part\":\"root\",\"pivot\":[0.0,0.25,0.0],\"rotate\":[0.0,0.0,0.0],\"submodels\":[{\"part\":\"head\",\"pivot\":[0.0,4.24,0.0],\"rotate\":[0.0,0.0,0.0],\"boxes\":[{\"coordinates\":[-2.5,4.24,-2.5,5.0,5.0,5.0],\"textureOffset\":[0.0,0.0]}]},{\"part\":\"body\",\"pivot\":[0.0,4.25,0.0],\"rotate\":[0.0,0.0,0.0],\"boxes\":[{\"coordinates\":[-1.5,0.25,-1.0,3.0,4.0,2.0],\"textureOffset\":[0.0,10.0]},{\"coordinates\":[-1.5,-0.75,-1.0,3.0,5.0,2.0],\"textureOffset\":[0.0,16.0],\"sizeAdd\":-0.2}],\"submodels\":[{\"part\":\"right_arLine truncated