From 606aa2e7046729709856a028b824181df52dcbc5 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Sun, 6 Sep 2026 03:43:08 -0400 Subject: [PATCH] Fixed object transform stability - Placement skips blocks omitted by unsupported rotations - Rotated object copies omit matching tile data - Scaling captures geometry and volume under one read lock - Regression tests cover omitted orientations and concurrent scaling --- .../object/IrisObjectPlacementRunner.java | 3 + .../engine/object/IrisObjectTransforms.java | 53 +++++++----- ...isObjectPlacementRunnerRegressionTest.java | 34 ++++++++ .../object/IrisObjectTransformsTileTest.java | 83 +++++++++++++++++++ 4 files changed, 152 insertions(+), 21 deletions(-) diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java index 8af511f43..ee36bdc4a 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java @@ -533,6 +533,9 @@ final class IrisObjectPlacementRunner { } data = config.getRotation().rotate(data, spinx, spiny, spinz); + if (data == null) { + continue; + } xx = x + (int) Math.round(i.getX()); int yy = y + (int) Math.round(i.getY()); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectTransforms.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectTransforms.java index 9e6780738..3925a2570 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectTransforms.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectTransforms.java @@ -30,10 +30,12 @@ import art.arcane.iris.util.project.interpolation.Interpolation3D; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; +import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; /** * Geometric transforms for {@link IrisObject}: rotation, scaling and the interpolated upscalers. @@ -52,15 +54,23 @@ final class IrisObjectTransforms { self.writeLock.lock(); try { VectorMap d = new VectorMap<>(); + Set omitted = new HashSet<>(); - for (var entry : self.blocks) { - d.put(r.rotate(entry.getKey(), spinx, spiny, spinz), r.rotate(entry.getValue(), spinx, spiny, spinz)); + for (Map.Entry entry : self.blocks) { + PlatformBlockState rotated = r.rotate(entry.getValue(), spinx, spiny, spinz); + if (rotated == null) { + omitted.add(entry.getKey()); + continue; + } + d.put(r.rotate(entry.getKey(), spinx, spiny, spinz), rotated); } VectorMap dx = new VectorMap<>(); - for (var entry : self.states) { - dx.put(r.rotate(entry.getKey(), spinx, spiny, spinz), entry.getValue()); + for (Map.Entry entry : self.states) { + if (!omitted.contains(entry.getKey())) { + dx.put(r.rotate(entry.getKey(), spinx, spiny, spinz), entry.getValue()); + } } self.blocks = d; @@ -100,26 +110,27 @@ final class IrisObjectTransforms { VectorMap placeTile = new VectorMap<>(); VectorMap placeMax = savedOrigin && scale > 1 ? new VectorMap<>() : null; - IrisVector center = new IrisVector(self.getCenter().getX(), self.getCenter().getY(), self.getCenter().getZ()); - if (self.getH() == 2) { - center = center.setY(center.getBlockY() + 0.5); - } - if (self.getW() == 2) { - center = center.setX(center.getBlockX() + 0.5); - } - if (self.getD() == 2) { - center = center.setZ(center.getBlockZ() + 0.5); - } - - IrisObject oo = savedOrigin ? createOriginScaledObject(self, scale) - : new IrisObject((int) Math.ceil((self.w * scale) + (scale * 2)), (int) Math.ceil((self.h * scale) + (scale * 2)), (int) Math.ceil((self.d * scale) + (scale * 2))); - oo.setLoadKey(self.getLoadKey()); - oo.setLoader(self.getLoader()); - oo.setLoadFile(self.getLoadFile()); - + IrisVector center; + IrisObject oo; boolean hasTiles; self.readLock.lock(); try { + center = new IrisVector(self.getCenter().getX(), self.getCenter().getY(), self.getCenter().getZ()); + if (self.getH() == 2) { + center = center.setY(center.getBlockY() + 0.5); + } + if (self.getW() == 2) { + center = center.setX(center.getBlockX() + 0.5); + } + if (self.getD() == 2) { + center = center.setZ(center.getBlockZ() + 0.5); + } + + oo = savedOrigin ? createOriginScaledObject(self, scale) + : new IrisObject((int) Math.ceil((self.w * scale) + (scale * 2)), (int) Math.ceil((self.h * scale) + (scale * 2)), (int) Math.ceil((self.d * scale) + (scale * 2))); + oo.setLoadKey(self.getLoadKey()); + oo.setLoader(self.getLoader()); + oo.setLoadFile(self.getLoadFile()); hasTiles = !self.states.isEmpty(); for (Map.Entry entry : self.blocks) { PlatformBlockState bd = entry.getValue(); diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementRunnerRegressionTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementRunnerRegressionTest.java index a46141d86..9eba2a65a 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementRunnerRegressionTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementRunnerRegressionTest.java @@ -10,6 +10,9 @@ import art.arcane.iris.spi.PlatformRegistries; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.math.RNG; +import org.bukkit.Material; +import org.bukkit.block.BlockFace; +import org.bukkit.block.data.Directional; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -19,6 +22,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -69,6 +73,36 @@ public class IrisObjectPlacementRunnerRegressionTest { IrisPlatforms.unbind(); } + @Test + public void unsupportedRotatedOrientationOmitsBlockTileAndListener() { + PlatformBlockState torch = state("minecraft:wall_torch", false); + Directional directional = mock(Directional.class); + Material material = mock(Material.class); + when(torch.nativeHandle()).thenReturn(directional); + when(directional.clone()).thenReturn(directional); + when(directional.getFacing()).thenReturn(BlockFace.NORTH); + when(directional.getFaces()).thenReturn(Set.of(BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST)); + when(directional.getMaterial()).thenReturn(material); + IrisObjectPlacement placement = placement(); + placement.setRotation(IrisObjectRotation.of(90, 0, 0)); + assertNull(placement.getRotation().rotate(torch, 0, 0, 0)); + IrisObject object = lineObject(3); + object.setUnsigned(1, 0, 0, torch); + object.setUnsignedTile(1, 0, 0, new TileData("minecraft:wall_torch", new KMap<>())); + RecordingPlacer placer = new RecordingPlacer(null); + List placed = new ArrayList<>(); + + int result = object.place(0, ANCHOR_Y, 0, placer, placement, new RNG(2L), + (position, state) -> placed.add(state), null, data); + + assertEquals(ANCHOR_Y, result); + assertEquals(2, placer.writes().size()); + assertEquals(List.of(solid, solid), placed); + assertNull(placer.get(0, ANCHOR_Y, 0)); + assertNull(placer.getData(0, ANCHOR_Y, 0, TileData.class)); + assertEquals(1, object.getStates().size()); + } + @Test public void placementFailurePropagatesToTheCaller() { RecordingPlacer placer = new RecordingPlacer(null); diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectTransformsTileTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectTransformsTileTest.java index 0059779c4..01d21393e 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectTransformsTileTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectTransformsTileTest.java @@ -16,6 +16,9 @@ import java.util.HashMap; import java.util.IdentityHashMap; import java.util.Map; import java.util.Set; +import java.util.concurrent.FutureTask; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.LockSupport; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -46,6 +49,40 @@ public class IrisObjectTransformsTileTest { IrisPlatforms.unbind(); } + @Test + public void rotationOmitsUnsupportedBlocksAndTheirTiles() { + IrisObject source = new IrisObject(3, 1, 1); + PlatformBlockState unsupported = state("minecraft:wall_torch"); + source.setUnsigned(0, 0, 0, unsupported); + source.setUnsignedTile(0, 0, 0, tile("minecraft:wall_torch", "omitted")); + source.setUnsigned(1, 0, 0, state("minecraft:chest")); + source.setUnsignedTile(1, 0, 0, tile("minecraft:chest", "kept")); + source.setUnsigned(2, 0, 0, state("minecraft:stone")); + IrisObjectRotation.StateRotator previous = IrisObjectRotation.bindPlatformRotator( + (rotation, block, x, y, z) -> block == unsupported ? null : block); + IrisObject rotated; + try { + rotated = source.rotateCopy(IrisObjectRotation.of(0, 90, 0)); + } finally { + IrisObjectRotation.restorePlatformRotator(previous); + } + + assertEquals(2, rotated.getBlocks().size()); + assertEquals(1, rotated.getStates().size()); + for (Map.Entry entry : rotated.getStates()) { + assertEquals("minecraft:chest", rotated.getBlocks().get(entry.getKey()).materialKey()); + assertEquals("kept", entry.getValue().getProperties().get("name")); + } + assertEquals(3, source.getBlocks().size()); + assertEquals(2, source.getStates().size()); + } + + @Test + public void scalingWaitsForMatchingGeometryAndVolume() throws Exception { + assertConsistentScaling(false); + assertConsistentScaling(true); + } + @Test public void enlargedTilesFollowEveryExpandedBlockAndCloneProperties() { IrisObject source = new IrisObject(1, 1, 1); @@ -186,6 +223,52 @@ public class IrisObjectTransformsTileTest { assertEquals(8, scaled.getStates().size()); } + private static void assertConsistentScaling(boolean savedOrigin) throws Exception { + IrisObject source = new IrisObject(9, 5, 3); + source.setUnsigned(0, 0, 0, state("minecraft:stone")); + FutureTask scaling = new FutureTask<>(() -> savedOrigin + ? source.scaledAroundOrigin(2, IrisObjectPlacementScaleInterpolator.NONE) + : source.scaled(2, IrisObjectPlacementScaleInterpolator.NONE)); + Thread worker = new Thread(scaling, "object-scaling-snapshot"); + source.writeLock.lock(); + try { + worker.start(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (worker.getState() != Thread.State.WAITING && System.nanoTime() < deadline) { + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); + } + assertEquals(Thread.State.WAITING, worker.getState()); + source.setW(2); + source.setH(2); + source.setD(2); + source.setCenter(new Vector3i(1, 1, 1)); + source.getBlocks().clear(); + source.setUnsigned(0, 0, 0, state("minecraft:chest")); + source.setUnsignedTile(0, 0, 0, tile("minecraft:chest", "new volume")); + } finally { + source.writeLock.unlock(); + } + try { + IrisObject actual = scaling.get(5, TimeUnit.SECONDS); + IrisObject expected = savedOrigin + ? source.scaledAroundOrigin(2, IrisObjectPlacementScaleInterpolator.NONE) + : source.scaled(2, IrisObjectPlacementScaleInterpolator.NONE); + assertEquals(expected.getCenter(), actual.getCenter()); + assertEquals(expected.getW(), actual.getW()); + assertEquals(expected.getH(), actual.getH()); + assertEquals(expected.getD(), actual.getD()); + assertEquals(expected.getBlocks().size(), actual.getBlocks().size()); + assertEquals(expected.getStates().size(), actual.getStates().size()); + for (Map.Entry entry : expected.getBlocks()) { + assertEquals(entry.getValue(), actual.getBlocks().get(entry.getKey())); + assertEquals(expected.getStates().get(entry.getKey()), actual.getStates().get(entry.getKey())); + } + } finally { + worker.interrupt(); + worker.join(TimeUnit.SECONDS.toMillis(5)); + } + } + private static IrisObject asymmetricObject() { IrisObject source = new IrisObject(7, 5, 3); source.setCenter(new Vector3i(5, 1, 2));