diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ModelBaker.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ModelBaker.java index d66f86c..a6fb9f2 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ModelBaker.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ModelBaker.java @@ -68,6 +68,8 @@ public final class ModelBaker { rescale = dto.rotation.rescale; } + Element modelElement = new Element(from, to, faces, rotOrigin, rotAxis, rotAngle, rescale); + // Apply blockstate variant rotation (90-degree steps) to box + faces + element rotation. for (int i = 0; i < xSteps; i++) { double[][] r = rotateBoxX(from, to); @@ -86,12 +88,6 @@ public final class ModelBaker { from = r[0]; to = r[1]; faces = rotateFacesY(faces); - // Without uvlock the top/bottom textures turn with the model (vanilla); side faces already - // keep their orientation through the face re-assignment. With uvlock they stay world-aligned. - if (!variant.uvlock()) { - faces[Direction.UP.ordinal()] = turned(faces[Direction.UP.ordinal()], 90); - faces[Direction.DOWN.ordinal()] = turned(faces[Direction.DOWN.ordinal()], 90); - } if (rotAxis >= 0) { rotOrigin = rotatePointY(rotOrigin); AxisRotation na = rotateAxisY(rotAxis); @@ -100,7 +96,10 @@ public final class ModelBaker { } } - baked.add(new Element(from, to, faces, rotOrigin, rotAxis, rotAngle, rescale)); + // Without uvlock, textures turn with the model (vanilla): keep the model-space element so the + // intersectors can sample it exactly. With uvlock they stay world-aligned (the rotated faces). + Element unrotated = (!variant.uvlock() && (xSteps != 0 || ySteps != 0)) ? modelElement : null; + baked.add(new Element(from, to, faces, rotOrigin, rotAxis, rotAngle, rescale, unrotated, xSteps, ySteps)); // Accumulate average color from the element's face textures. for (Face f : faces) { @@ -140,9 +139,13 @@ public final class ModelBaker { */ private double[] defaultUv(Direction dir, double[] f, double[] t) { return switch (dir) { - case UP, DOWN -> new double[] {f[0], f[2], t[0], t[2]}; - case NORTH, SOUTH -> new double[] {f[0], 1 - t[1], t[0], 1 - f[1]}; - case WEST, EAST -> new double[] {f[2], 1 - t[1], t[2], 1 - f[1]}; + // Vanilla BlockElement.uvsByFace (u/v in 0..1). + case DOWN -> new double[] {f[0], 1 - t[2], t[0], 1 - f[2]}; + case UP -> new double[] {f[0], f[2], t[0], t[2]}; + case NORTH -> new double[] {1 - t[0], 1 - t[1], 1 - f[0], 1 - f[1]}; + case SOUTH -> new double[] {f[0], 1 - t[1], t[0], 1 - f[1]}; + case WEST -> new double[] {f[2], 1 - t[1], t[2], 1 - f[1]}; + case EAST -> new double[] {1 - t[2], 1 - t[1], 1 - f[2], 1 - f[1]}; }; } @@ -168,12 +171,6 @@ public final class ModelBaker { }; } - /** The face with its texture lookup rotated by a further {@code deg} (90° steps). */ - private static Face turned(Face f, int deg) { - if (f == null) return null; - return new Face(f.texture, f.u1, f.v1, f.u2, f.v2, (f.rotation + deg) % 360, f.tintIndex); - } - // --- 90-degree box rotations around the block center (0.5, 0.5, 0.5) --- private double[][] rotateBoxY(double[] from, double[] to) { diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Element.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Element.java index 77c619c..3f0c06d 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Element.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Element.java @@ -19,6 +19,13 @@ public final class Element { public final double rotAngleRad; // radians public final boolean rescale; + // Blockstate rotation WITHOUT uvlock: the model-space element and the 90° steps (x first, then y) that + // turn it into this world-space one. Textures stay bound to the model, so intersectors hit the + // unrotated element with the inverse-rotated ray. Null/0 when unrotated or uvlocked. + public final Element unrotated; + public final int xSteps; + public final int ySteps; + public Element( double[] from, double[] to, @@ -27,6 +34,23 @@ public final class Element { int rotAxis, double rotAngleRad, boolean rescale) { + this(from, to, faces, rotOrigin, rotAxis, rotAngleRad, rescale, null, 0, 0); + } + + public Element( + double[] from, + double[] to, + Face[] faces, + double[] rotOrigin, + int rotAxis, + double rotAngleRad, + boolean rescale, + Element unrotated, + int xSteps, + int ySteps) { + this.unrotated = unrotated; + this.xSteps = xSteps; + this.ySteps = ySteps; this.from = from; this.to = to; this.faces = faces; @@ -36,6 +60,29 @@ public final class Element { this.rescale = rescale; } + /** Model space -> world: vanilla blockstate rotation (x steps, then y steps; clockwise about the centre). */ + public static double[] rotate(double x, double y, double z, int xSteps, int ySteps, boolean point) { + double c = point ? 0.5 : 0; + double dx = x - c, dy = y - c, dz = z - c; + for (int i = 0; i < xSteps; i++) { + double ny = dz, nz = -dy; // (dy, dz) -> (dz, -dy) + dy = ny; + dz = nz; + } + for (int i = 0; i < ySteps; i++) { + double nx = -dz, nz = dx; // (dx, dz) -> (-dz, dx) + dx = nx; + dz = nz; + } + return new double[] {dx + c, dy + c, dz + c}; + } + + /** World -> model space: the inverse of {@link #rotate}. */ + public static double[] unrotate(double x, double y, double z, int xSteps, int ySteps, boolean point) { + double[] p = rotate(x, y, z, 0, (4 - ySteps) % 4, point); + return rotate(p[0], p[1], p[2], (4 - xSteps) % 4, 0, point); + } + public boolean isAxisAligned() { return rotAxis < 0 || rotAngleRad == 0.0; } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityCube.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityCube.java index e9ec16c..c17bb84 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityCube.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityCube.java @@ -16,15 +16,7 @@ public final class EntityCube { public final double[] aabbMin = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE}; public final double[] aabbMax = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE}; - /** Faces use BLOCK-model UV orientation (resolved block-model faces) instead of entity box-UV. */ - public final boolean blockUv; - public EntityCube(double[] from, double[] to, Face[] faces, Affine toWorld) { - this(from, to, faces, toWorld, false); - } - - public EntityCube(double[] from, double[] to, Face[] faces, Affine toWorld, boolean blockUv) { - this.blockUv = blockUv; this.from = from; this.to = to; this.faces = faces; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityIntersector.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityIntersector.java index d8e212d..b5e4a52 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityIntersector.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityIntersector.java @@ -2,6 +2,7 @@ package eu.mhsl.minecraft.pixelpics.render.entity; import eu.mhsl.minecraft.pixelpics.assets.model.Direction; import eu.mhsl.minecraft.pixelpics.assets.model.Face; +import eu.mhsl.minecraft.pixelpics.render.raytrace.ElementIntersector; import eu.mhsl.minecraft.pixelpics.render.raytrace.FaceHit; import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; import org.bukkit.util.Vector; @@ -85,52 +86,9 @@ public final class EntityIntersector { double fx = frac(px, cube.from[0], cube.to[0]); double fy = frac(py, cube.from[1], cube.to[1]); double fz = frac(pz, cube.from[2], cube.to[2]); - // (s,t) = Blockbench/Java box-UV (lerp_x, lerp_y) for this face (see BoxUv). Front/right faces - // run their horizontal axis opposite to back/left (they're viewed from the other side). - double s, tt; - if (cube.blockUv) { - // Block-model faces (see ElementIntersector): side faces run along +x / +z, up/down along x,z. - switch (dir) { - case UP, DOWN -> { - s = fx; - tt = fz; - } - case NORTH, SOUTH -> { - s = fx; - tt = 1 - fy; - } - default -> { - s = fz; - tt = 1 - fy; - } - } - } else - switch (dir) { - case UP -> { - s = fx; - tt = fz; - } - case DOWN -> { - s = fx; - tt = 1 - fz; - } - case NORTH -> { - s = 1 - fx; - tt = 1 - fy; - } - case SOUTH -> { - s = fx; - tt = 1 - fy; - } - case EAST -> { - s = 1 - fz; - tt = 1 - fy; - } - default -> { - s = fz; - tt = 1 - fy; - } // WEST - } + // Vanilla face-UV orientation, shared by block-model faces and entity box-UV (see BoxUv). + double[] st = ElementIntersector.faceUv(dir, fx, fy, fz); + double s = st[0], tt = st[1]; int color = face.sample(s, tt); if (!ColorUtil.opaque(color)) return null; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemBaker.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemBaker.java index 668ea83..2f57944 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemBaker.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemBaker.java @@ -179,8 +179,7 @@ public final class CemBaker implements EntityBaker { List cubes = new ArrayList<>(baked.size()); for (CemGeometry.Baked b : baked) cubes.add(new EntityCube(b.from(), b.to(), b.faces(), place.mul(b.world()))); - for (EntityCube c : itemCubes) - cubes.add(new EntityCube(c.from, c.to, c.faces, place.mul(c.toWorld), c.blockUv)); + for (EntityCube c : itemCubes) cubes.add(new EntityCube(c.from, c.to, c.faces, place.mul(c.toWorld))); return RenderedEntity.of(cubes); } 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 b6e9a24..6127950 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 @@ -109,10 +109,14 @@ public final class ItemGeometry { if (data == null) return; ResolvedModel model = registry.get(data); if (model == null || !model.hasGeometry) return; - for (Element e : model.elements) { + for (Element world : model.elements) { + // Rotated without uvlock: bake the model-space element turned by the blockstate rotation, so + // textures stay bound to the model (vanilla); otherwise the world-space element as is. + Element e = world.unrotated != null ? world.unrotated : world; + Affine variant = world.unrotated != null ? variantRotation(world.xSteps, world.ySteps) : Affine.identity(); Face[] faces = new Face[6]; for (int i = 0; i < 6; i++) faces[i] = tint(e.faces[i], data); - out.add(new EntityCube(e.from, e.to, faces, toWorld.mul(elementTransform(e)), true)); + out.add(new EntityCube(e.from, e.to, faces, toWorld.mul(variant).mul(elementTransform(e)))); } } @@ -121,7 +125,7 @@ public final class ItemGeometry { if (state == null || blockEntities == null) return; RenderedEntity r = blockEntities.bake(state); // placed at cell (bx,by,bz) = (0,0,0) if (r == null) return; - for (EntityCube c : r.cubes) out.add(new EntityCube(c.from, c.to, c.faces, toWorld.mul(c.toWorld), c.blockUv)); + for (EntityCube c : r.cubes) out.add(new EntityCube(c.from, c.to, c.faces, toWorld.mul(c.toWorld))); } // --- item definition / model resolution -------------------------------------------------------- @@ -426,6 +430,14 @@ public final class ItemGeometry { return boxes; } + /** Vanilla blockstate rotation (x steps then y steps, clockwise, about the block centre); see Element#rotate. */ + private static Affine variantRotation(int xSteps, int ySteps) { + return Affine.translation(0.5, 0.5, 0.5) + .mul(Affine.rotY(-Math.PI / 2 * ySteps)) + .mul(Affine.rotX(-Math.PI / 2 * xSteps)) + .mul(Affine.translation(-0.5, -0.5, -0.5)); + } + /** Element rotation (origin/axis/angle, optional rescale) as an affine in block units. */ private static Affine elementTransform(Element e) { if (e.isAxisAligned()) return Affine.identity(); diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/ElementIntersector.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/ElementIntersector.java index 3c39aaa..9875a0d 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/ElementIntersector.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/ElementIntersector.java @@ -40,9 +40,26 @@ public final class ElementIntersector { List candidates = new ArrayList<>(model.elements.size()); for (int i = 0; i < model.elements.size(); i++) { Element element = model.elements.get(i); - Candidate c = element.isAxisAligned() - ? intersectAabb(element, ox, oy, oz, dx, dy, dz) - : intersectObb(element, ox, oy, oz, dx, dy, dz); + Candidate c; + if (element.unrotated != null) { + // Blockstate rotation without uvlock: hit the model-space element with the inverse-rotated ray + // (same t: rigid transform), so faces and UVs stay bound to the model like in vanilla. + Element m = element.unrotated; + double[] mo = Element.unrotate(ox, oy, oz, element.xSteps, element.ySteps, true); + double[] md = Element.unrotate(dx, dy, dz, element.xSteps, element.ySteps, false); + c = m.isAxisAligned() + ? intersectAabb(m, mo[0], mo[1], mo[2], md[0], md[1], md[2]) + : intersectObb(m, mo[0], mo[1], mo[2], md[0], md[1], md[2]); + if (c != null) { + double[] n = Element.rotate( + c.normal[0], c.normal[1], c.normal[2], element.xSteps, element.ySteps, false); + c = new Candidate(c.element, c.t, c.dir, c.s, c.t2, n, 0); + } + } else { + c = element.isAxisAligned() + ? intersectAabb(element, ox, oy, oz, dx, dy, dz) + : intersectObb(element, ox, oy, oz, dx, dy, dz); + } if (c != null) candidates.add(new Candidate(c.element, c.t, c.dir, c.s, c.t2, c.normal, i)); } if (candidates.isEmpty()) return null; @@ -149,26 +166,28 @@ public final class ElementIntersector { double fracY = frac(py, from[1], to[1]); double fracZ = frac(pz, from[2], to[2]); - double s, t; - switch (dir) { - // Texture V is top-down (0 = texture top). For side faces the texture top is the block - // top (high Y), so t = 1 - fracY. - case UP, DOWN -> { - s = fracX; - t = fracZ; - } - case NORTH, SOUTH -> { - s = fracX; - t = 1 - fracY; - } - default -> { - s = fracZ; - t = 1 - fracY; - } // WEST, EAST - } + double[] st = faceUv(dir, fracX, fracY, fracZ); + double s = st[0], t = st[1]; return new Candidate(e, tEntry, dir, s, t, normal, 0); } + /** + * Face-local (s, t) in 0..1 for a hit at block fractions (fx, fy, fz), matching vanilla's + * {@code BlockElement.uvsByFace}: a face's UV rect {@code [u1,v1,u2,v2]} is lerped with these, so north/east + * faces run their horizontal axis towards -x/-z (left-to-right seen from outside) and down runs v towards + * north. Shared with the entity intersector (entity box-UV uses the same orientation). + */ + public static double[] faceUv(Direction dir, double fx, double fy, double fz) { + return switch (dir) { + case UP -> new double[] {fx, fz}; + case DOWN -> new double[] {fx, 1 - fz}; + case NORTH -> new double[] {1 - fx, 1 - fy}; + case SOUTH -> new double[] {fx, 1 - fy}; + case WEST -> new double[] {fz, 1 - fy}; + case EAST -> new double[] {1 - fz, 1 - fy}; + }; + } + private static Direction faceFor(int axis, boolean negFace) { return switch (axis) { case 0 -> negFace ? Direction.WEST : Direction.EAST; diff --git a/tools/edgecase-plugin/pptest/PPTest.java b/tools/edgecase-plugin/pptest/PPTest.java index ec57131..14caa60 100644 --- a/tools/edgecase-plugin/pptest/PPTest.java +++ b/tools/edgecase-plugin/pptest/PPTest.java @@ -51,6 +51,10 @@ public class PPTest extends JavaPlugin { scenes.put("rotated", new Scene("rotated", this::rotated, 3, 4.5, -4, 3, 0.5, 2)); scenes.put("rotated2", new Scene("rotated2", this::rotated, -0.5, 3.5, 9.5, -0.5, 0.5, 5)); scenes.put("redstone", new Scene("redstone", this::redstone, 3, 3, -3, 3, 0, 2)); + scenes.put("piston", new Scene("piston", this::piston, 4.5, 3.2, -3.5, 5, 1, 1)); + scenes.put("pistonback", new Scene("pistonback", this::piston, 5.5, 3.2, 5.5, 5, 1, 1)); + scenes.put("pistondown", new Scene("pistondown", this::piston, -1.6, 2.6, -1.6, 0.5, 1.4, 0.5)); + scenes.put("pistonup", new Scene("pistonup", this::piston, 0.4, 2.6, -1.6, 2.5, 1.4, 0.5)); scenes.put("vehicles", new Scene("vehicles", this::vehicles, 5, 3, -7, 5, 0.6, 1.5)); } @@ -442,6 +446,22 @@ public class PPTest extends JavaPlugin { put.accept(new int[] {4, 0, 3}, "blue_stained_glass_pane[west=true,north=true]"); } + /** x/y blockstate rotations without uvlock: pistons in all six directions, observers, dispensers, logs. */ + private void piston(Location o) { + String[] f = {"down", "up", "north", "south", "east", "west"}; + for (int i = 0; i < f.length; i++) { + o.clone().add(i * 2, 1, 0).getBlock().setBlockData( + Bukkit.createBlockData("minecraft:piston[facing=" + f[i] + ",extended=false]"), false); + o.clone().add(i * 2, 1, 2).getBlock().setBlockData( + Bukkit.createBlockData("minecraft:observer[facing=" + f[i] + "]"), false); + o.clone().add(i * 2, 0, 2).getBlock().setBlockData( + Bukkit.createBlockData("minecraft:dispenser[facing=" + f[i] + "]"), false); + } + o.clone().add(0, 0, -2).getBlock().setBlockData(Bukkit.createBlockData("minecraft:oak_log[axis=x]"), false); + o.clone().add(2, 0, -2).getBlock().setBlockData(Bukkit.createBlockData("minecraft:oak_log[axis=z]"), false); + o.clone().add(4, 0, -2).getBlock().setBlockData(Bukkit.createBlockData("minecraft:furnace[facing=north]"), false); + } + private void vehicles(Location o) { spawn(o, 0, 0, 0, 0, EntityType.CHEST_MINECART); spawn(o, 1.8, 0, 0, 90, EntityType.FURNACE_MINECART);