From 0c74b68cbe2e03cd237ed597d7d14788e4853706 Mon Sep 17 00:00:00 2001 From: Michael Burgess Date: Sun, 9 Aug 2026 12:11:16 -0400 Subject: [PATCH] Fix ring-orientation detection, which was breaking horizontal (and some vertical) gates The plane-lock fix from a couple rounds back bootstrapped its axis detection with an orthogonal-only (face-to-face) flood, reasoning that couldn't leak through depth. But on a stepped/diagonal ring, an orthogonal-only sample from the seed is just a short straight run - which has zero variance in TWO axes, not only the true thickness one. inPlaneDirections()'s tie-breaking (checks X thinnest, then Z, falls back to Y) then frequently picked the wrong axis: a horizontal ring's true thin axis (Y) was never even reachable once a straight run made X or Z look equally "thin" by coincidence. Whether a given gate linked successfully came down to which direction the seed's first few connected blocks happened to run in - explaining the inconsistent failures across otherwise-identical builds. Fixed by making the bootstrap diagonal-inclusive again (safe now that frame-materials defaults to just obsidian, so bootstrap leak risk is minimal) so it actually samples the ring's real 2D footprint before deciding the plane, then re-deriving the plane once more from the full, properly-flooded frame set as a second safety net. Also generalized pickChevrons()/walkRing(), which hardcoded `row = block.getY()` - a vertical-ring assumption that's meaningless for a horizontal ring where Y is constant across the whole thing. Replaced with project(), which picks the correct pair of plane axes for whatever orientation the ring actually has. --- .../paper/gate/GateStructureScanner.java | 79 ++++++++++++------- 1 file changed, 52 insertions(+), 27 deletions(-) diff --git a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateStructureScanner.java b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateStructureScanner.java index b39b9e0..0eaeda7 100644 --- a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateStructureScanner.java +++ b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateStructureScanner.java @@ -15,9 +15,10 @@ import java.util.Set; /** * Flood-fill scanner that discovers a gate's physical structure starting from a punched - * frame block: the connected frame ring (any size/shape the builder makes), the enclosed - * interior ("iris"), and a fixed number of chevrons evenly spaced around the ring's actual - * geometric perimeter. + * frame block: the connected frame ring (any size/shape or orientation the builder makes - + * vertical against a wall, horizontal flat on the ground, whatever), the enclosed interior + * ("iris"), and a fixed number of chevrons evenly spaced around the ring's actual geometric + * perimeter. */ public class GateStructureScanner { @@ -25,6 +26,26 @@ public class GateStructureScanner { BlockFace.UP, BlockFace.DOWN, BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST }; + // Used only to sample enough of the ring's real shape to determine its plane. A ring's + // corners commonly step diagonally, so an orthogonal-only sample would just be a short + // straight run - which has zero variance in TWO axes, not just the true thickness one, + // and reliably fools axis detection. Diagonal connectivity here is fine: it's discarded + // after orientation is known, and the real frame flood below stays plane-locked. + private static final int[][] ALL_26_OFFSETS = buildAll26Offsets(); + + private static int[][] buildAll26Offsets() { + List offsets = new ArrayList<>(); + for (int dx = -1; dx <= 1; dx++) { + for (int dy = -1; dy <= 1; dy++) { + for (int dz = -1; dz <= 1; dz++) { + if (dx == 0 && dy == 0 && dz == 0) continue; + offsets.add(new int[]{dx, dy, dz}); + } + } + } + return offsets.toArray(new int[0][]); + } + public static final class ScanResult { public final GateStructure structure; // null on failure public final String failureReason; // null on success @@ -71,13 +92,7 @@ public class GateStructureScanner { return ScanResult.fail("That block isn't listed in gate.frame-materials."); } - // Diagonal ("stepped") connectivity must only ever apply within the ring's own flat - // plane - never through its thickness - or the flood can leak sideways into an - // unrelated nearby structure built from the same materials (a decorative backdrop - // wall, for instance). We don't know the plane yet, so bootstrap it with a small, - // orthogonal-only (face-to-face) flood first, which can't leak through depth at all, - // then lock diagonal moves to that plane for the real flood. - List bootstrap = orthogonalFlood(seed, 24); + List bootstrap = diagonalBootstrapFlood(seed, 40); BlockFace[] inPlane = inPlaneDirections(bootstrap); int[][] planeOffsets = planeOffsetsFor(inPlane); @@ -107,6 +122,10 @@ public class GateStructureScanner { } } + // The bootstrap only sampled a corner of the ring; re-derive the plane from the full, + // properly-flooded frame set in case the bootstrap's smaller sample was ambiguous. + inPlane = inPlaneDirections(frameBlocks); + if (frameBlocks.size() < minFrameBlocks) { return ScanResult.fail("Only found " + frameBlocks.size() + " connected frame block(s), need at least " + minFrameBlocks + " (gate.min-frame-blocks)."); @@ -139,8 +158,8 @@ public class GateStructureScanner { + "gate.max-iris-blocks (" + maxIrisBlocks + ")."); } - /** Small orthogonal-only (face-to-face) flood used purely to sample the ring's shape before we know its plane. */ - private List orthogonalFlood(Block seed, int cap) { + /** Samples the ring's real shape (diagonal-inclusive) purely to determine its plane before the real, plane-locked flood. */ + private List diagonalBootstrapFlood(Block seed, int cap) { Set visited = new HashSet<>(); List found = new ArrayList<>(); Deque queue = new ArrayDeque<>(); @@ -150,8 +169,8 @@ public class GateStructureScanner { while (!queue.isEmpty() && found.size() < cap) { Block cur = queue.poll(); found.add(cur); - for (BlockFace face : ORTHOGONAL) { - Block next = cur.getRelative(face); + for (int[] d : ALL_26_OFFSETS) { + Block next = cur.getRelative(d[0], d[1], d[2]); long k = key(next); if (visited.contains(k)) continue; if (isFrameMaterial(next.getType())) { @@ -169,8 +188,6 @@ public class GateStructureScanner { for (BlockFace f : ORTHOGONAL) { offsets.add(new int[]{f.getModX(), f.getModY(), f.getModZ()}); } - // Only two of the three axes appear in inPlane (the other is the thickness axis); - // add the 4 corner combinations of those two axes. boolean usesX = false, usesY = false, usesZ = false; for (BlockFace f : inPlane) { if (f.getModX() != 0) usesX = true; @@ -232,24 +249,33 @@ public class GateStructureScanner { } } + /** Projects a block onto the ring's 2D plane (whatever orientation it is) as (row, col). */ + private int[] project(Block b, BlockFace[] inPlane) { + boolean usesX = false, usesY = false, usesZ = false; + for (BlockFace f : inPlane) { + if (f.getModX() != 0) usesX = true; + if (f.getModY() != 0) usesY = true; + if (f.getModZ() != 0) usesZ = true; + } + if (usesX && usesY) return new int[]{b.getY(), b.getX()}; // thickness axis is Z (a vertical ring facing N/S) + if (usesX && usesZ) return new int[]{b.getX(), b.getZ()}; // thickness axis is Y (a horizontal ring) + return new int[]{b.getY(), b.getZ()}; // thickness axis is X (a vertical ring facing E/W) + } + /** * Picks {@code chevronCount} chevrons evenly spaced around the ring's actual geometric * perimeter - walks the ring in order (each frame block should have exactly two ring * neighbours) and takes evenly-spaced indices from that walk, not raw scan-discovery order. */ private List pickChevrons(List frameBlocks, BlockFace[] inPlane) { - boolean horizontalIsX = false; - for (BlockFace f : inPlane) if (f.getModX() != 0) horizontalIsX = true; - Map plane = new HashMap<>(); for (Block b : frameBlocks) { - int col = horizontalIsX ? b.getX() : b.getZ(); - int row = b.getY(); - long k = (((long) row) << 32) ^ (col & 0xFFFFFFFFL); + int[] rc = project(b, inPlane); + long k = (((long) rc[0]) << 32) ^ (rc[1] & 0xFFFFFFFFL); plane.putIfAbsent(k, b); } - List ordered = walkRing(frameBlocks, plane, horizontalIsX); + List ordered = walkRing(frameBlocks, plane, inPlane); List ring = (ordered != null && ordered.size() >= 4) ? ordered : frameBlocks; int count = Math.max(0, Math.min(chevronCount, ring.size())); @@ -272,14 +298,13 @@ public class GateStructureScanner { }; /** Walks the ring in geometric order assuming each frame block has exactly two in-plane neighbours (orthogonal or diagonal, for stepped rings); null if that assumption fails. */ - private List walkRing(List frameBlocks, Map plane, boolean horizontalIsX) { + private List walkRing(List frameBlocks, Map plane, BlockFace[] inPlane) { Map> adjacency = new HashMap<>(); for (Block b : frameBlocks) { - int col = horizontalIsX ? b.getX() : b.getZ(); - int row = b.getY(); + int[] rc = project(b, inPlane); List neighbors = new ArrayList<>(); for (int[] d : PLANE_8_OFFSETS) { - long k = (((long) (row + d[0])) << 32) ^ ((col + d[1]) & 0xFFFFFFFFL); + long k = (((long) (rc[0] + d[0])) << 32) ^ ((rc[1] + d[1]) & 0xFFFFFFFFL); Block n = plane.get(k); if (n != null && n != b) neighbors.add(n); }