Fixed object placement bounds

- Native structure vetoes use the full transformed object bounds
- Object copies hold the volume read lock during mutation-sensitive reads
- Object copies retain independent tiles without redundant coordinate clones
- Regression tests cover oblique rotations and off-center object origins
- Concurrent copy tests verify volume lock ownership
This commit is contained in:
Brian Neumann-Fopiano
2026-09-06 03:08:00 -04:00
parent 17c927acdd
commit d55c77572b
4 changed files with 129 additions and 22 deletions
@@ -110,7 +110,7 @@ public class IrisObject extends IrisRegistrant {
this.d = d;
center = new Vector3i(w / 2, h / 2, d / 2);
shrinkOffset = new Vector3i(0, 0, 0);
var lock = new ReentrantReadWriteLock();
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
readLock = lock.readLock();
writeLock = lock.writeLock();
}
@@ -135,17 +135,22 @@ public class IrisObject extends IrisRegistrant {
return aabb.aquire(() -> getAABBFor(new IrisBlockVector(w, h, d)));
}
public synchronized IrisObject copy() {
IrisObject o = new IrisObject(w, h, d);
o.setLoadKey(getLoadKey());
o.setLoader(getLoader());
o.setLoadFile(getLoadFile());
o.setCenter(getCenter().clone());
public IrisObject copy() {
readLock.lock();
try {
IrisObject copy = new IrisObject(w, h, d);
copy.setLoadKey(getLoadKey());
copy.setLoader(getLoader());
copy.setLoadFile(getLoadFile());
copy.setCenter(getCenter().clone());
blocks.forEach((i, v) -> o.blocks.put(i.clone(), v));
states.forEach((i, v) -> o.states.put(i.clone(), v.clone()));
blocks.forEach(copy.blocks::put);
states.forEach((position, tile) -> copy.states.put(position, tile.clone()));
return o;
return copy;
} finally {
readLock.unlock();
}
}
public void readLegacy(InputStream in) throws IOException {
@@ -350,7 +350,7 @@ final class IrisObjectPlacementRunner {
return -1;
}
if (!rawStructurePiece && nativeStructureVetoes(placer, config, spin, translating, translateOffset, ceilingHang,
if (!rawStructurePiece && nativeStructureVetoes(placer, config, spin, placementBounds, translating, translateOffset, ceilingHang,
yv < 0 && config.getMode() == ObjectPlaceMode.PAINT, warpMargin, x, y + yrand, z)) {
return -1;
}
@@ -955,7 +955,7 @@ final class IrisObjectPlacementRunner {
* near this placement means no per block work at all. Only when the placement envelope meets a piece does the
* precise pass run, and the first solid block inside a piece rejects the whole object before any write.
*/
private boolean nativeStructureVetoes(IObjectPlacer placer, IrisObjectPlacement config, SpinKernel spin,
private boolean nativeStructureVetoes(IObjectPlacer placer, IrisObjectPlacement config, SpinKernel spin, TransformedBounds bounds,
boolean translating, IrisBlockVector translateOffset, boolean ceilingHang,
boolean paint, int warpMargin, int x, int y, int z) {
Engine engine = placer.getEngine();
@@ -963,23 +963,21 @@ final class IrisObjectPlacementRunner {
return false;
}
int margin = (Math.max(self.getW(), Math.max(self.getH(), self.getD())) / 2) + 1 + warpMargin;
if (translating) {
margin += Math.max(Math.abs(translateOffset.getBlockX()),
Math.max(Math.abs(translateOffset.getBlockY()), Math.abs(translateOffset.getBlockZ())));
}
KList<NativeStructureVolume> volumes = engine.getNativeStructureVolumes(x - margin, z - margin, x + margin, z + margin);
int minX = x + bounds.minX();
int maxX = x + bounds.maxX();
int minZ = z + bounds.minZ();
int maxZ = z + bounds.maxZ();
KList<NativeStructureVolume> volumes = engine.getNativeStructureVolumes(minX, minZ, maxX, maxZ);
if (volumes == null || volumes.isEmpty()) {
return false;
}
int worldY = y + engine.getMinHeight();
int envelopeMinY = paint ? Integer.MIN_VALUE : worldY - margin;
int envelopeMaxY = paint ? Integer.MAX_VALUE : worldY + margin;
int envelopeMinY = paint ? Integer.MIN_VALUE : worldY + bounds.minY() - warpMargin;
int envelopeMaxY = paint ? Integer.MAX_VALUE : worldY + bounds.maxY() + warpMargin;
boolean envelopeMeetsPiece = false;
for (NativeStructureVolume volume : volumes) {
if (volume.intersects(x - margin, envelopeMinY, z - margin, x + margin, envelopeMaxY, z + margin)) {
if (volume.intersects(minX, envelopeMinY, minZ, maxX, envelopeMaxY, maxZ)) {
envelopeMeetsPiece = true;
break;
}
@@ -0,0 +1,74 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.util.common.math.IrisBlockVector;
import art.arcane.iris.util.common.math.Vector3i;
import art.arcane.volmlib.util.collection.KMap;
import org.junit.Test;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
public class IrisObjectCopyTest {
@Test
public void copyWaitsForVolumeMutationToComplete() throws Exception {
IrisObject source = new IrisObject(1, 1, 1);
PlatformBlockState block = mock(PlatformBlockState.class);
CountDownLatch started = new CountDownLatch(1);
try (ExecutorService executor = Executors.newSingleThreadExecutor()) {
Future<IrisObject> result;
source.writeLock.lock();
try {
result = executor.submit(() -> {
started.countDown();
return source.copy();
});
assertTrue(started.await(5, TimeUnit.SECONDS));
assertThrows(TimeoutException.class, () -> result.get(150, TimeUnit.MILLISECONDS));
source.setW(3);
source.setCenter(new Vector3i(1, 0, 0));
source.setUnsigned(2, 0, 0, block);
} finally {
source.writeLock.unlock();
}
IrisObject copy = result.get(5, TimeUnit.SECONDS);
assertEquals(3, copy.getW());
assertEquals(new Vector3i(1, 0, 0), copy.getCenter());
assertSame(block, copy.getBlocks().get(new IrisBlockVector(1, 0, 0)));
}
}
@Test
public void copyOwnsItsVolumeCoordinatesAndTiles() {
IrisObject source = new IrisObject(1, 1, 1);
PlatformBlockState block = mock(PlatformBlockState.class);
TileData tile = new TileData("minecraft:chest", new KMap<>());
tile.getProperties().put("name", "source");
source.setUnsigned(0, 0, 0, block);
source.setUnsignedTile(0, 0, 0, tile);
IrisObject copy = source.copy();
IrisBlockVector position = new IrisBlockVector(0, 0, 0);
TileData copiedTile = copy.getStates().get(position);
assertNotSame(source.getCenter(), copy.getCenter());
assertNotSame(tile, copiedTile);
assertNotSame(tile.getProperties(), copiedTile.getProperties());
copiedTile.getProperties().put("name", "copy");
copy.getBlocks().remove(position);
assertEquals("source", tile.getProperties().get("name"));
assertSame(block, source.getBlocks().get(position));
}
}
@@ -8,6 +8,7 @@ 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.Vector3i;
import art.arcane.iris.util.project.stream.ProceduralStream;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
@@ -101,6 +102,35 @@ public class NativeStructureObjectVetoTest {
assertTrue(placer.written().isEmpty());
}
@Test
public void obliqueRotationRejectsNativePiecesBeyondTheUnrotatedRadius() {
IrisObject object = new IrisObject(21, 1, 21);
object.setUnsigned(20, 0, 20, log);
IrisObjectPlacement placement = placement();
placement.setBottom(false);
placement.setRotation(IrisObjectRotation.of(0, 45, 0));
RecordingPlacer placer = new RecordingPlacer(engine);
volumes(volume(14, SURFACE_Y, 0, 14, SURFACE_Y, 0));
assertEquals(-1, object.place(0, SURFACE_Y, 0, placer, placement, new RNG(1234L), data));
assertTrue(placer.written().isEmpty());
}
@Test
public void offCenterOriginRejectsNativePiecesAtTheFarEdge() {
IrisObject object = new IrisObject(9, 1, 1);
object.setCenter(new Vector3i(0, 0, 0));
object.setUnsigned(8, 0, 0, log);
IrisObjectPlacement placement = placement();
placement.setBottom(false);
placement.setRotation(IrisObjectRotation.of(0, 0, 0));
RecordingPlacer placer = new RecordingPlacer(engine);
volumes(volume(8, SURFACE_Y, 0, 8, SURFACE_Y, 0));
assertEquals(-1, object.place(0, SURFACE_Y, 0, placer, placement, new RNG(1234L), data));
assertTrue(placer.written().isEmpty());
}
@Test
public void pieceOverlappingOnlyTheEnvelopeStillPlaces() {
RecordingPlacer placer = new RecordingPlacer(engine);