From 606aa2e7046729709856a028b824181df52dcbc5 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Sun, 6 Sep 2026 03:43:08 -0400 Subject: [PATCH 1/2] 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)); From 1a2c8a88458d27274cdfc0bba5191bdcf1d6ce77 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Sun, 6 Sep 2026 03:43:25 -0400 Subject: [PATCH 2/2] Simplified object smart boring - Smart boring runs direct axis passes under the existing write lock - Negative object bounds stop at the last occupied coordinate - Bore insertion avoids redundant map callbacks - Regression tests cover ordered fills and repeated calls --- .../iris/engine/object/IrisObjectShaping.java | 161 +++++++----------- .../engine/object/IrisObjectShapingTest.java | 127 ++++++++++++++ 2 files changed, 192 insertions(+), 96 deletions(-) create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisObjectShapingTest.java diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectShaping.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectShaping.java index 5b31f2bf4..61cba47f9 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectShaping.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectShaping.java @@ -23,13 +23,9 @@ import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.VectorMap; import art.arcane.iris.util.common.math.IrisBlockVector; import art.arcane.iris.util.common.math.Vector3i; -import art.arcane.iris.util.common.parallel.BurstExecutor; -import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; -import java.util.concurrent.atomic.AtomicInteger; - /** * Volume shaping for {@link IrisObject}: smart boring, shrinkwrapping, compaction and the shared block * classification helpers used by placement. @@ -59,30 +55,23 @@ final class IrisObjectShaping { private static void ensureSmartBoredLocked(IrisObject self) { PrecisionStopwatch p = PrecisionStopwatch.start(); PlatformBlockState vair = IrisObject.States.VAIR; - AtomicInteger applied = new AtomicInteger(); - IrisBlockVector max = new IrisBlockVector(Double.MIN_VALUE, Double.MIN_VALUE, Double.MIN_VALUE); - IrisBlockVector min = new IrisBlockVector(Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE); - VectorMap source; - self.readLock.lock(); - try { - if (self.blocks.isEmpty()) { - IrisLogging.warn("Cannot Smart Bore " + self.getLoadKey() + " because it has 0 blocks in it."); - self.smartBored = true; - return; - } + int applied = 0; + IrisBlockVector max = new IrisBlockVector(Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MIN_VALUE); + IrisBlockVector min = new IrisBlockVector(Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE); + VectorMap source = self.blocks; + if (source.isEmpty()) { + IrisLogging.warn("Cannot Smart Bore " + self.getLoadKey() + " because it has 0 blocks in it."); + self.smartBored = true; + return; + } - source = self.blocks; - - for (IrisBlockVector i : self.blocks.keys()) { - max.setX(Math.max(i.getX(), max.getX())); - min.setX(Math.min(i.getX(), min.getX())); - max.setY(Math.max(i.getY(), max.getY())); - min.setY(Math.min(i.getY(), min.getY())); - max.setZ(Math.max(i.getZ(), max.getZ())); - min.setZ(Math.min(i.getZ(), min.getZ())); - } - } finally { - self.readLock.unlock(); + for (IrisBlockVector i : source.keys()) { + max.setX(Math.max(i.getX(), max.getX())); + min.setX(Math.min(i.getX(), min.getX())); + max.setY(Math.max(i.getY(), max.getY())); + min.setY(Math.min(i.getY(), min.getY())); + max.setZ(Math.max(i.getZ(), max.getZ())); + min.setZ(Math.min(i.getZ(), min.getZ())); } VectorMap bore = new VectorMap<>(); @@ -90,114 +79,94 @@ final class IrisObjectShaping { // bored volume must come from a fixed X->Y->Z order on the calling (lock-owning) // thread, not from pool scheduling. This matches what burst-worker callers (studio, // generation threads) already produced, so output is unchanged where it was stable. - BurstExecutor burst = MultiBurst.burst.burst(); - burst.setMulticore(false); // Smash X for (int rayY = min.getBlockY(); rayY <= max.getBlockY(); rayY++) { - int finalRayY = rayY; - burst.queue(() -> { - for (int rayZ = min.getBlockZ(); rayZ <= max.getBlockZ(); rayZ++) { - int start = Integer.MAX_VALUE; - int end = Integer.MIN_VALUE; + for (int rayZ = min.getBlockZ(); rayZ <= max.getBlockZ(); rayZ++) { + int start = Integer.MAX_VALUE; + int end = Integer.MIN_VALUE; - for (int ray = min.getBlockX(); ray <= max.getBlockX(); ray++) { - if (boreContains(source, bore, new IrisBlockVector(ray, finalRayY, rayZ))) { - start = Math.min(ray, start); - end = Math.max(ray, end); - } - } - - if (start != Integer.MAX_VALUE && end != Integer.MIN_VALUE) { - for (int i = start; i <= end; i++) { - boreCell(source, bore, new IrisBlockVector(i, finalRayY, rayZ), vair, applied); - } + for (int ray = min.getBlockX(); ray <= max.getBlockX(); ray++) { + if (boreContains(source, bore, new IrisBlockVector(ray, rayY, rayZ))) { + start = Math.min(ray, start); + end = Math.max(ray, end); } } - }); + + if (start != Integer.MAX_VALUE && end != Integer.MIN_VALUE) { + for (int i = start; i <= end; i++) { + applied += boreCell(source, bore, new IrisBlockVector(i, rayY, rayZ), vair); + } + } + } } // Smash Y for (int rayX = min.getBlockX(); rayX <= max.getBlockX(); rayX++) { - int finalRayX = rayX; - burst.queue(() -> { - for (int rayZ = min.getBlockZ(); rayZ <= max.getBlockZ(); rayZ++) { - int start = Integer.MAX_VALUE; - int end = Integer.MIN_VALUE; + for (int rayZ = min.getBlockZ(); rayZ <= max.getBlockZ(); rayZ++) { + int start = Integer.MAX_VALUE; + int end = Integer.MIN_VALUE; - for (int ray = min.getBlockY(); ray <= max.getBlockY(); ray++) { - if (boreContains(source, bore, new IrisBlockVector(finalRayX, ray, rayZ))) { - start = Math.min(ray, start); - end = Math.max(ray, end); - } - } - - if (start != Integer.MAX_VALUE && end != Integer.MIN_VALUE) { - for (int i = start; i <= end; i++) { - boreCell(source, bore, new IrisBlockVector(finalRayX, i, rayZ), vair, applied); - } + for (int ray = min.getBlockY(); ray <= max.getBlockY(); ray++) { + if (boreContains(source, bore, new IrisBlockVector(rayX, ray, rayZ))) { + start = Math.min(ray, start); + end = Math.max(ray, end); } } - }); + + if (start != Integer.MAX_VALUE && end != Integer.MIN_VALUE) { + for (int i = start; i <= end; i++) { + applied += boreCell(source, bore, new IrisBlockVector(rayX, i, rayZ), vair); + } + } + } } // Smash Z for (int rayX = min.getBlockX(); rayX <= max.getBlockX(); rayX++) { - int finalRayX = rayX; - burst.queue(() -> { - for (int rayY = min.getBlockY(); rayY <= max.getBlockY(); rayY++) { - int start = Integer.MAX_VALUE; - int end = Integer.MIN_VALUE; + for (int rayY = min.getBlockY(); rayY <= max.getBlockY(); rayY++) { + int start = Integer.MAX_VALUE; + int end = Integer.MIN_VALUE; - for (int ray = min.getBlockZ(); ray <= max.getBlockZ(); ray++) { - if (boreContains(source, bore, new IrisBlockVector(finalRayX, rayY, ray))) { - start = Math.min(ray, start); - end = Math.max(ray, end); - } - } - - if (start != Integer.MAX_VALUE && end != Integer.MIN_VALUE) { - for (int i = start; i <= end; i++) { - boreCell(source, bore, new IrisBlockVector(finalRayX, rayY, i), vair, applied); - } + for (int ray = min.getBlockZ(); ray <= max.getBlockZ(); ray++) { + if (boreContains(source, bore, new IrisBlockVector(rayX, rayY, ray))) { + start = Math.min(ray, start); + end = Math.max(ray, end); } } - }); - } - burst.complete(); - - self.writeLock.lock(); - try { - if (!self.smartBored) { - bore.forEach((v, s) -> self.blocks.computeIfAbsent(v, (vv) -> s)); - self.smartBored = true; + if (start != Integer.MAX_VALUE && end != Integer.MIN_VALUE) { + for (int i = start; i <= end; i++) { + applied += boreCell(source, bore, new IrisBlockVector(rayX, rayY, i), vair); + } + } } - } finally { - self.writeLock.unlock(); } - IrisLogging.debug("Smart Bore: " + self.getLoadKey() + " in " + Form.duration(p.getMilliseconds(), 2) + " (" + Form.f(applied.get()) + ")"); + source.putAll(bore); + self.smartBored = true; + + IrisLogging.debug("Smart Bore: " + self.getLoadKey() + " in " + Form.duration(p.getMilliseconds(), 2) + " (" + Form.f(applied) + ")"); } private static boolean boreContains(VectorMap source, VectorMap bore, IrisBlockVector v) { return source.containsKey(v) || bore.containsKey(v); } - private static void boreCell(VectorMap source, VectorMap bore, IrisBlockVector v, PlatformBlockState vair, AtomicInteger applied) { + private static int boreCell(VectorMap source, VectorMap bore, IrisBlockVector v, PlatformBlockState vair) { PlatformBlockState existing = source.get(v); if (existing == null) { if (vair.equals(bore.get(v))) { - return; + return 0; } - bore.computeIfAbsent(v, (vv) -> vair); + bore.put(v, vair); } else if (vair.equals(existing)) { - return; + return 0; } - applied.getAndIncrement(); + return 1; } static void shrinkwrap(IrisObject self) { diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectShapingTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectShapingTest.java new file mode 100644 index 000000000..713adfdd8 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectShapingTest.java @@ -0,0 +1,127 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.util.common.math.IrisBlockVector; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisObjectShapingTest { + @Before + public void bindPlatform() { + IrisPlatforms.unbind(); + PlatformRegistries registries = mock(PlatformRegistries.class); + Map states = new HashMap<>(); + when(registries.block(anyString())).thenAnswer(invocation -> states.computeIfAbsent( + invocation.getArgument(0), key -> mock(PlatformBlockState.class))); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + } + + @After + public void unbindPlatform() { + IrisPlatforms.unbind(); + } + + @Test + public void smartBoreMatchesOrderedAxisClosureAndPreservesOriginalBlocks() { + Random random = new Random(617431L); + PlatformBlockState stone = mock(PlatformBlockState.class); + for (int fixture = 0; fixture < 24; fixture++) { + int offset = switch (fixture % 3) { + case 0 -> -1050; + case 1 -> 1020; + default -> -3; + }; + IrisObject object = new IrisObject(6, 6, 6); + Map expected = new HashMap<>(); + for (int index = 0; index < 36; index++) { + Cell cell = new Cell(offset + random.nextInt(6), offset + random.nextInt(6), offset + random.nextInt(6)); + PlatformBlockState state = switch (index % 3) { + case 0 -> IrisObject.States.AIR; + case 1 -> IrisObject.States.VAIR; + default -> stone; + }; + object.getBlocks().put(cell.vector(), state); + expected.put(cell, state); + } + for (int axis = 0; axis < 3; axis++) { + closeAxis(expected, axis); + } + + IrisObjectShaping.ensureSmartBored(object); + + assertTrue(object.isSmartBored()); + assertEquals("fixture " + fixture, expected.size(), object.getBlocks().size()); + for (Map.Entry entry : expected.entrySet()) { + assertSame(entry.getValue(), object.getBlocks().get(entry.getKey().vector())); + } + long revision = object.getBlocks().modificationRevision(); + IrisObjectShaping.ensureSmartBored(object); + assertEquals(revision, object.getBlocks().modificationRevision()); + } + } + + @Test + public void emptySmartBoreCompletesWithoutAddingBlocks() { + IrisObject object = new IrisObject(1, 1, 1); + + IrisObjectShaping.ensureSmartBored(object); + + assertTrue(object.isSmartBored()); + assertTrue(object.getBlocks().isEmpty()); + } + + private static void closeAxis(Map blocks, int axis) { + List occupied = new ArrayList<>(blocks.keySet()); + for (Cell start : occupied) { + for (Cell end : occupied) { + if (!start.with(axis, 0).equals(end.with(axis, 0))) { + continue; + } + for (int position = start.coordinate(axis); position <= end.coordinate(axis); position++) { + blocks.putIfAbsent(start.with(axis, position), IrisObject.States.VAIR); + } + } + } + } + + private record Cell(int x, int y, int z) { + private int coordinate(int axis) { + return switch (axis) { + case 0 -> x; + case 1 -> y; + default -> z; + }; + } + + private Cell with(int axis, int coordinate) { + return switch (axis) { + case 0 -> new Cell(coordinate, y, z); + case 1 -> new Cell(x, coordinate, z); + default -> new Cell(x, y, coordinate); + }; + } + + private IrisBlockVector vector() { + return new IrisBlockVector(x, y, z); + } + } +}