From a6aeceff862ae7b1196e70f8a14eb790303878c0 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Sun, 18 Oct 2026 18:00:40 -0400 Subject: [PATCH] Grounded procedural trees - Compact connected roots extend bounded gaps into supporting terrain - Unsupported cliff placements leave no partial trees - Root and canopy roles follow object scaling and rotation - Canopies preserve solid trunk cells across chunk replay and saved continuations - Branch wood replaces leaf clusters within each tree - Regression tests cover slopes and overlapping tree placements - Gameplay checks compare grounded spindle geometry across both packs --- .../src/test/gameplay/tree-grounding.mjs | 143 +++++ .../decoration/tree/IrisProceduralTree.java | 4 +- .../tree/ProceduralTreeGenerator.java | 39 +- .../decoration/tree/TreeCanopyBuilder.java | 4 +- .../decoration/tree/TreeGrounding.java | 122 +++++ .../decoration/tree/TreeRootBuilder.java | 172 +++--- .../CaveObjectPlacementTransaction.java | 64 ++- .../FloatingObjectPlacementTransaction.java | 72 ++- .../mantle/GoldenDebugObjectPlacer.java | 11 + .../mantle/ObjectContinuationPersistence.java | 92 ++++ .../mantle/ObjectDestinationTransaction.java | 39 +- .../generation/mantle/ObjectSourcePlan.java | 16 +- .../iris/generation/mantle/TreeCanopy.java | 5 + .../iris/generation/runtime/EngineMode.java | 57 +- .../structure/object/DecayControlPlacer.java | 11 + .../object/HeightmapObjectPlacer.java | 11 + .../iris/structure/object/IObjectPlacer.java | 9 + .../iris/structure/object/IrisObject.java | 8 + .../object/IrisObjectPlacementRunner.java | 100 ++-- .../structure/object/IrisObjectShaping.java | 18 + .../object/IrisObjectTransforms.java | 83 ++- .../storage/matter/IrisMatterSupport.java | 1 + .../storage/matter/TreeCanopyMatter.java | 39 ++ .../tree/TreeCanopyBuilderTest.java | 62 +++ .../decoration/tree/TreeRootBuilderTest.java | 218 ++++++++ .../CaveObjectPlacementTransactionTest.java | 22 + ...loatingObjectPlacementTransactionTest.java | 24 + .../mantle/ObjectContinuationCanopyTest.java | 221 ++++++++ .../mantle/ObjectContinuationTest.java | 14 +- .../ObjectDestinationTransactionTest.java | 85 +++ .../mantle/ObjectSourcePlanCacheTest.java | 2 +- .../mantle/ObjectSourcePlanTest.java | 30 +- .../schema/SchemaBuilderIdentityTest.java | 2 +- .../object/TreeGroundingPlacementTest.java | 498 ++++++++++++++++++ 34 files changed, 2082 insertions(+), 216 deletions(-) create mode 100644 adapters/bukkit/plugin/src/test/gameplay/tree-grounding.mjs create mode 100644 core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeGrounding.java create mode 100644 core/src/main/java/art/arcane/iris/generation/mantle/TreeCanopy.java create mode 100644 core/src/main/java/art/arcane/iris/world/storage/matter/TreeCanopyMatter.java create mode 100644 core/src/test/java/art/arcane/iris/generation/decoration/tree/TreeCanopyBuilderTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/decoration/tree/TreeRootBuilderTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/mantle/ObjectContinuationCanopyTest.java create mode 100644 core/src/test/java/art/arcane/iris/structure/object/TreeGroundingPlacementTest.java diff --git a/adapters/bukkit/plugin/src/test/gameplay/tree-grounding.mjs b/adapters/bukkit/plugin/src/test/gameplay/tree-grounding.mjs new file mode 100644 index 000000000..f9ca63788 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/tree-grounding.mjs @@ -0,0 +1,143 @@ +import { readFile, realpath } from 'node:fs/promises' +import path from 'node:path' + +const nonGround = new Set(['air', 'cave_air', 'void_air', 'water', 'lava', 'nether_wart_block', 'warped_wart_block']) +const chunkOffsets = [-56, -40, -24, -8, 8, 24, 40, 56] + +function isWood(block) { + return block !== null && /_(wood|hyphae|log|stem)$/.test(block.name) +} + +function isGround(block) { + return block !== null && block.boundingBox === 'block' && !nonGround.has(block.name) + && !block.name.endsWith('_leaves') && !isWood(block) +} + +function positionKey(position) { + return `${position.x},${position.y},${position.z}` +} + +function groundSpread(bot, base) { + const heights = [] + for (const x of [-3, 0, 3]) { + for (const z of [-3, 0, 3]) { + for (let y = base.y + 6; y >= base.y - 8; y--) { + if (!isGround(bot.blockAt(base.offset(x, y - base.y, z)))) continue + heights.push(y) + break + } + } + } + return heights.length === 9 ? Math.max(...heights) - Math.min(...heights) : null +} + +function inspectTrees(context) { + const bot = context.bot + const center = bot.entity.position.floored() + const woodIds = Object.values(bot.registry.blocksByName).filter(isWood).map(block => block.id) + const positions = bot.findBlocks({ matching: woodIds, point: center, maxDistance: 64, count: 20000 }) + const columns = new Map() + for (const position of positions) { + const key = `${position.x},${position.z}` + if (!columns.has(key)) columns.set(key, []) + columns.get(key).push(position) + } + const trees = [] + for (const column of columns.values()) { + column.sort((first, second) => first.y - second.y) + const base = column[0] + const height = column[column.length - 1].y - base.y + 1 + if (column.length < 8 || height < 8 || column.length / height < 0.65) continue + if (column.some(position => position.distanceTo(center) >= 60)) continue + if (![0, 1, 2, 3].every(y => isWood(bot.blockAt(base.offset(0, y, 0))))) continue + const upperGaps = [] + for (let y = base.y; y < base.y + height; y++) { + const block = bot.blockAt(base.offset(0, y - base.y, 0)) + if (block === null || !isWood(block)) upperGaps.push({ y, block: block?.name ?? null }) + } + const grounded = isGround(bot.blockAt(base.offset(0, -1, 0))) + const spread = groundSpread(bot, base) + trees.push({ base: { x: base.x, y: base.y, z: base.z }, woodBlocks: column.length, + height, grounded, terrainSpread: spread, upperGaps }) + } + return trees +} + +export default { + name: 'iris-tree-grounding', + description: 'Verify grounded spindle bases, continuous overlapping trunks, and paired pack geometry.', + async run(context) { + const directory = await realpath(context.server.directory) + const source = await readFile(path.join(directory, '.server-source'), 'utf8') + context.expect(/^isolated=true\s*$/m.test(source), 'Tree generation requires an isolated instance') + const packs = await realpath(path.join(directory, 'plugins', 'Iris', 'packs')) + context.expect(packs.startsWith(`${directory}${path.sep}`), 'Tree tests require local pack copies') + context.report.treeWorlds = [] + context.report.treeGrounding = [] + context.report.treeContinuity = [] + for (const pack of ['overworld', 'underworld']) { + const dimension = JSON.parse(await readFile(path.join(packs, pack, 'dimensions', `${pack}.json`), 'utf8')) + context.expect(dimension.focus === 'estranged/spindlewood', 'Pack must focus on spindlewood', { pack }) + await context.step(`create ${pack} spindle world`, async () => { + const world = `tree_${pack}_${Date.now().toString(36)}` + const response = await context.command(`/iris create name=${world} type=${pack} seed=78264193`, + /Successfully created your world|Failed to create/, 240000) + context.expect(response.includes('Successfully created your world'), response) + context.report.treeWorlds.push({ pack, name: world, seed: 78264193, biome: dimension.focus }) + if (context.bot.game.gameMode !== 'spectator') { + await context.command('/minecraft:gamemode spectator', /spectator/i, 30000) + } + await context.waitUntil(() => context.bot.game.gameMode === 'spectator', + { label: 'Spectator mode for terrain inspection', timeoutMs: 30000, intervalMs: 100 }) + }) + await context.step(`${pack} trees connect to flat and sloped terrain`, async () => { + const origin = context.bot.entity.position.floored() + const inspected = new Map() + for (const offset of [0, 80, 160]) { + await context.command(`/minecraft:tp @s ${origin.x + offset + 0.5} ${origin.y + 12} ${origin.z + 0.5}`, + /Teleported/i, 30000) + await context.waitUntil(() => { + const center = context.bot.entity.position.floored() + if (Math.abs(center.x - origin.x - offset) > 1) return false + return chunkOffsets.every(x => chunkOffsets.every(z => context.bot.blockAt(center.offset(x, 0, z)) !== null)) + }, { label: 'Complete terrain neighborhood', timeoutMs: 90000, intervalMs: 250 }) + const trees = inspectTrees(context) + for (const tree of trees) { + context.expect(tree.grounded, 'Generated spindle trunk base has no terrain connection', { pack, ...tree }) + inspected.set(positionKey(tree.base), tree) + } + if (inspected.size >= 8 && [...inspected.values()].some(tree => tree.terrainSpread >= 2)) break + } + const trees = [...inspected.values()] + const sloped = trees.filter(tree => tree.terrainSpread >= 2) + context.expect(trees.length >= 8, 'Expected at least eight generated spindle trunk bases', { pack, trees }) + context.expect(sloped.length > 0, 'Expected a grounded tree on uneven terrain', { pack, trees }) + context.report.treeGrounding.push({ pack, trees: trees.length, sloped: sloped.length, samples: trees }) + }) + await context.step(`${pack} overlapping canopy preserves the seeded main trunk`, async () => { + await context.command('/minecraft:tp @s 0.5 88 0.5', /Teleported/i, 30000) + await context.waitUntil(() => { + const center = context.bot.entity.position.floored() + return Math.abs(center.x) < 2 && Math.abs(center.z) < 2 + && context.bot.blockAt(center.offset(-7 - center.x, 69 - center.y, 6 - center.z)) !== null + }, { label: 'Seeded trunk neighborhood', timeoutMs: 90000, intervalMs: 250 }) + const center = context.bot.entity.position.floored() + const blocks = [] + for (let y = 69; y <= 83; y++) { + const block = context.bot.blockAt(center.offset(-7 - center.x, y - center.y, 6 - center.z)) + context.expect(isWood(block), 'Overlapping canopy interrupted the seeded main trunk', + { pack, x: -7, y, z: 6, block: block?.name ?? null }) + blocks.push({ y, block: block.name }) + } + context.report.treeContinuity.push({ pack, x: -7, z: 6, blocks }) + }) + } + await context.step('paired packs retain matching sampled tree geometry', async () => { + const geometry = context.report.treeGrounding.map(result => result.samples.map(tree => ({ + base: tree.base, woodBlocks: tree.woodBlocks, height: tree.height, terrainSpread: tree.terrainSpread + })).sort((first, second) => positionKey(first.base).localeCompare(positionKey(second.base)))) + context.expect(JSON.stringify(geometry[0]) === JSON.stringify(geometry[1]), + 'Paired spindle packs produced different sampled geometry', { geometry }) + }) + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/tree/IrisProceduralTree.java b/core/src/main/java/art/arcane/iris/generation/decoration/tree/IrisProceduralTree.java index 1bb503f6d..7ce9e393b 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/tree/IrisProceduralTree.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/tree/IrisProceduralTree.java @@ -242,11 +242,11 @@ public class IrisProceduralTree implements IrisProceduralPlacement { private IrisTreeRootStyle rootStyle = IrisTreeRootStyle.BUTTRESS; @MinNumber(0) - @Description("Override the root depth/reach in blocks. 0 means auto (scales with tree height).") + @Description("Root depth in blocks, also used for ground-contact extension up to 16 blocks. 0 scales with tree height.") private int rootDepth = 0; @MinNumber(0) - @Description("Override the root flare radius in blocks. 0 means auto.") + @Description("Horizontal reach of buttress and stilt roots. 0 uses a compact radius based on trunk width. Taproots ignore this field.") private double rootFlare = 0; @MinNumber(1) diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/tree/ProceduralTreeGenerator.java b/core/src/main/java/art/arcane/iris/generation/decoration/tree/ProceduralTreeGenerator.java index 2c662a7f3..1e380242a 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/tree/ProceduralTreeGenerator.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/tree/ProceduralTreeGenerator.java @@ -40,6 +40,12 @@ public final class ProceduralTreeGenerator { long baseSeed = rng.getSeed(); List limbs = TreeTrunkBuilder.build(canvas, tree, height); + Set supportPositions = new HashSet<>(); + for (TreeBlockCanvas.Vec position : canvas.getTrunk()) { + if (position.y() == 0) { + supportPositions.add(position); + } + } boolean wantEndpoints = tree.getDecorators() != null && !tree.getDecorators().isEmpty(); List branchEndpoints = wantEndpoints ? new ArrayList<>() : null; @@ -55,7 +61,13 @@ public final class ProceduralTreeGenerator { } if (tree.isRoots()) { + Set existingWood = new HashSet<>(canvas.getTrunk()); TreeRootBuilder.build(canvas, tree, height, baseSeed); + for (TreeBlockCanvas.Vec position : canvas.getTrunk()) { + if (position.y() <= 0 && !existingWood.contains(position)) { + supportPositions.add(position); + } + } } if (tree.isPlausible()) { @@ -82,10 +94,14 @@ public final class ProceduralTreeGenerator { TreePlausibility.apply(resolved, trunkPositions, leafPositions, tree); - return assemble(resolved); + int rootReach = tree.isRoots() ? tree.getRootDepth() > 0 ? tree.getRootDepth() + : Math.max(2, (int) Math.round(height * 0.18)) : 0; + return assemble(resolved, supportPositions, leafPositions, rootReach); } - private static IrisObject assemble(Map resolved) { + private static IrisObject assemble(Map resolved, + Set supportPositions, + Set leafPositions, int rootReach) { if (resolved.isEmpty()) { return null; } @@ -105,21 +121,26 @@ public final class ProceduralTreeGenerator { maxZ = Math.max(maxZ, v.z()); } - int w = maxX - minX + 1; + int w = 2 * Math.max(Math.abs(minX), Math.abs(maxX)) + 1; int h = maxY - minY + 1; - int d = maxZ - minZ + 1; - int cx = w / 2; + int d = 2 * Math.max(Math.abs(minZ), Math.abs(maxZ)) + 1; int cy = h / 2; - int cz = d / 2; IrisObject object = new IrisObject(w, h, d); + List roots = new ArrayList<>(supportPositions.size()); + Set canopy = new HashSet<>(leafPositions.size()); for (Map.Entry entry : resolved.entrySet()) { TreeBlockCanvas.Vec v = entry.getKey(); - int nx = v.x() - minX - cx; int ny = v.y() - cy + 1; - int nz = v.z() - minZ - cz; - object.getBlocks().put(new IrisBlockVector(nx, ny, nz), entry.getValue()); + object.getBlocks().put(new IrisBlockVector(v.x(), ny, v.z()), entry.getValue()); + if (supportPositions.contains(v)) { + roots.add(new TreeGrounding.Root(v.x(), ny, v.z(), entry.getValue())); + } + if (leafPositions.contains(v)) { + canopy.add(new TreeBlockCanvas.Vec(v.x(), ny, v.z())); + } } + object.setTreeGrounding(TreeGrounding.create(roots, rootReach, canopy)); return object; } diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeCanopyBuilder.java b/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeCanopyBuilder.java index 369c772f9..1146c16af 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeCanopyBuilder.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeCanopyBuilder.java @@ -335,7 +335,9 @@ public final class TreeCanopyBuilder { int x = (int) Math.round(ox + dx * t); int y = (int) Math.round(oy + dy * t - sagBlocks * t * t); int z = (int) Math.round(oz + dz * t); - if (!canvas.has(x, y, z)) { + TreeBlockCanvas.Cell existing = canvas.get(x, y, z); + if (existing == null || existing.role() == TreeBlockCanvas.Role.LEAF + || existing.role() == TreeBlockCanvas.Role.SECONDARY_LEAF) { canvas.setTrunk(x, y, z, TreeBlockCanvas.Role.TRUNK, axis); } tip = new int[]{x, y, z}; diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeGrounding.java b/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeGrounding.java new file mode 100644 index 000000000..68df796d6 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeGrounding.java @@ -0,0 +1,122 @@ +package art.arcane.iris.generation.decoration.tree; + +import art.arcane.iris.generation.geometry.IrisBlockVector; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.structure.object.IObjectPlacer; +import art.arcane.iris.structure.object.IrisObjectPlacement; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.BlockPosition; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public record TreeGrounding(List roots, int maxExtension, Set canopy) { + public TreeGrounding { + roots = List.copyOf(roots); + maxExtension = Math.max(0, Math.min(16, maxExtension)); + canopy = Set.copyOf(canopy); + } + + public static TreeGrounding create(List roots, int maxExtension, Set canopy) { + Map columns = new HashMap<>(); + for (Root root : roots) { + long key = ((long) root.x() << 32) | (root.z() & 0xffffffffL); + Root previous = columns.get(key); + if (previous == null || root.y() < previous.y()) { + columns.put(key, root); + } + } + List lowest = new ArrayList<>(columns.values()); + lowest.sort(Comparator.comparingInt(Root::x).thenComparingInt(Root::z)); + return new TreeGrounding(lowest, maxExtension, canopy); + } + + public boolean isCanopy(int x, int y, int z) { + return canopy.contains(new TreeBlockCanvas.Vec(x, y, z)); + } + + public KList supportOffsets() { + KList offsets = new KList<>(); + for (Root root : roots) { + offsets.add(new IrisBlockVector(root.x(), root.y(), root.z())); + } + return offsets; + } + + public Plan plan(IObjectPlacer placer, Placement placement) { + List extensions = new ArrayList<>(); + IrisObjectPlacement config = placement.config(); + BlockPosition origin = placement.origin(); + BlockPosition spin = placement.spin(); + int solidDepth = Math.max(1, config.getSurfaceSupportDepth()); + for (Root root : roots) { + IrisBlockVector offset = config.getRotation().rotate( + new IrisBlockVector(root.x(), root.y(), root.z()), spin.getX(), spin.getY(), spin.getZ()); + offset = config.getTranslate().translate(offset, config.getRotation(), spin.getX(), spin.getY(), spin.getZ()); + int x = origin.getX() + (int) Math.round(offset.getX()); + int y = origin.getY() + (int) Math.round(offset.getY()); + int z = origin.getZ() + (int) Math.round(offset.getZ()); + int surface = placer.getHighest(x, z, placement.data(), true); + int contact = Math.min(surface, y - 1); + if (y - contact - 1 > maxExtension) { + return Plan.UNSUPPORTED; + } + for (int below = 0; below < solidDepth; below++) { + if (placer.isCarved(x, contact - below, z) || !placer.isSurfaceSolid(x, contact - below, z)) { + return Plan.UNSUPPORTED; + } + } + if (contact < y - 1) { + NativeBlockState state = config.getRotation().rotate(root.state(), spin.getX(), spin.getY(), spin.getZ()); + if (state == null) { + return Plan.UNSUPPORTED; + } + Extension extension = new Extension(x, y - 1, contact, z, state); + if (intersectsStructure(placer, extension)) { + return Plan.UNSUPPORTED; + } + extensions.add(extension); + } + } + return new Plan(!roots.isEmpty(), List.copyOf(extensions)); + } + + private static boolean intersectsStructure(IObjectPlacer placer, Extension extension) { + Engine engine = placer.getEngine(); + if (engine == null) { + return false; + } + KList volumes = engine.getNativeStructureVolumes( + extension.x(), extension.z(), extension.x(), extension.z()); + if (volumes == null) { + return false; + } + for (NativeStructureVolume volume : volumes) { + if (volume.intersects(extension.x(), extension.bottomY() + engine.getMinHeight(), extension.z(), + extension.x(), extension.topY() + engine.getMinHeight(), extension.z())) { + return true; + } + } + return false; + } + + public record Root(int x, int y, int z, NativeBlockState state) { + } + + public record Placement(IrisData data, IrisObjectPlacement config, BlockPosition origin, BlockPosition spin) { + } + + public record Extension(int x, int topY, int bottomY, int z, NativeBlockState state) { + } + + public record Plan(boolean supported, List extensions) { + private static final Plan UNSUPPORTED = new Plan(false, List.of()); + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeRootBuilder.java b/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeRootBuilder.java index 80f331ed5..49e2feb78 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeRootBuilder.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeRootBuilder.java @@ -28,120 +28,152 @@ public final class TreeRootBuilder { } public static void build(TreeBlockCanvas canvas, IrisProceduralTree tree, int height, long baseSeed) { - List cells = new ArrayList<>(); + List cells = new ArrayList<>(); for (TreeBlockCanvas.Vec v : canvas.getTrunk()) { if (v.y() == 0) { - cells.add(new int[]{v.x(), v.z()}); + cells.add(v); } } if (cells.isEmpty()) { return; } + cells.sort(TreeBlockCanvas.Vec::compareTo); int depth = tree.getRootDepth() > 0 ? tree.getRootDepth() : autoDepth(height); RNG rng = new RNG(baseSeed ^ 0x5009L); double cx = 0; double cz = 0; - for (int[] c : cells) { - cx += c[0]; - cz += c[1]; + for (TreeBlockCanvas.Vec cell : cells) { + cx += cell.x(); + cz += cell.z(); } cx /= cells.size(); cz /= cells.size(); double baseRadius = 1.0; - for (int[] c : cells) { - baseRadius = Math.max(baseRadius, Math.hypot(c[0] - cx, c[1] - cz) + 0.5); + for (TreeBlockCanvas.Vec cell : cells) { + baseRadius = Math.max(baseRadius, Math.hypot(cell.x() - cx, cell.z() - cz) + 0.5); } - double flare = tree.getRootFlare() > 0 ? tree.getRootFlare() : baseRadius + Math.max(2.0, depth * 0.6); + double flare = tree.getRootFlare() > 0 ? tree.getRootFlare() : baseRadius + Math.max(0.75, baseRadius * 0.35); + RootShape shape = new RootShape(cells, cx, cz, baseRadius, depth, flare); IrisTreeRootStyle style = tree.getRootStyle(); switch (style) { - case TAPROOT -> taproot(canvas, cells, cx, cz, baseRadius, depth); + case TAPROOT -> taproot(canvas, shape); case BUTTRESS -> { - taproot(canvas, cells, cx, cz, baseRadius, depth); - buttress(canvas, cells, cx, cz, baseRadius, depth, flare, rng); + taproot(canvas, shape); + buttress(canvas, shape, rng); } case STILT -> { - taproot(canvas, cells, cx, cz, baseRadius, Math.min(depth, 3)); - stilts(canvas, cx, cz, baseRadius, flare, height, cells.size(), rng); + stilts(canvas, shape, height, rng); + taproot(canvas, new RootShape(cells, cx, cz, baseRadius, Math.min(depth, 3), flare)); } } } - private static void taproot(TreeBlockCanvas canvas, List cells, double cx, double cz, double baseRadius, int depth) { - int icx = (int) Math.round(cx); - int icz = (int) Math.round(cz); - for (int k = 1; k <= depth; k++) { - double frac = k / (double) depth; - double keepR = baseRadius * (1.0 - 0.6 * frac); - for (int[] c : cells) { - if (Math.hypot(c[0] - cx, c[1] - cz) <= keepR + 1e-6) { - placeRoot(canvas, c[0], -k, c[1], TreeBlockCanvas.Axis.Y); + private static void taproot(TreeBlockCanvas canvas, RootShape shape) { + TreeBlockCanvas.Vec center = nearestCell(shape.cells(), shape.centerX(), shape.centerZ()); + for (int k = 1; k <= shape.depth(); k++) { + double fraction = k / (double) shape.depth(); + double keepRadius = shape.radius() * (1.0 - fraction); + for (TreeBlockCanvas.Vec cell : shape.cells()) { + if (Math.hypot(cell.x() - shape.centerX(), cell.z() - shape.centerZ()) <= keepRadius) { + placeRoot(canvas, cell.x(), -k, cell.z(), TreeBlockCanvas.Axis.Y); } } - placeRoot(canvas, icx, -k, icz, TreeBlockCanvas.Axis.Y); + placeRoot(canvas, center.x(), -k, center.z(), TreeBlockCanvas.Axis.Y); } } - private static void buttress(TreeBlockCanvas canvas, List cells, double cx, double cz, double baseRadius, - int depth, double flare, RNG rng) { - int nLegs = Math.max(4, Math.min(12, cells.size() + 2)); - for (int li = 0; li < nLegs; li++) { - double ang = 2.0 * Math.PI * li / nLegs + rng.d(-0.25, 0.25); - double ca = Math.cos(ang); - double sa = Math.sin(ang); - int prevX = (int) Math.round(cx + baseRadius * ca); - int prevZ = (int) Math.round(cz + baseRadius * sa); - for (int k = 1; k <= depth; k++) { - double frac = k / (double) depth; - double r = baseRadius + (flare - baseRadius) * frac; - int x = (int) Math.round(cx + r * ca); - int z = (int) Math.round(cz + r * sa); - placeRoot(canvas, x, -k, z, TreeBlockCanvas.Axis.Y); - if (x != prevX || z != prevZ) { - placeRoot(canvas, prevX, -k, prevZ, TreeBlockCanvas.Axis.Y); - } - prevX = x; - prevZ = z; + private static void buttress(TreeBlockCanvas canvas, RootShape shape, RNG rng) { + int legs = Math.max(3, Math.min(8, shape.cells().size() + 2)); + for (int leg = 0; leg < legs; leg++) { + double angle = 2.0 * Math.PI * leg / legs + rng.d(-0.25, 0.25); + double reach = shape.flare() * rng.d(0.8, 1.0); + int x = (int) Math.round(shape.centerX() + reach * Math.cos(angle)); + int z = (int) Math.round(shape.centerZ() + reach * Math.sin(angle)); + int depth = legDepth(shape.depth(), leg, legs); + TreeBlockCanvas.Vec start = nearestCell(shape.cells(), x, z); + rasterizeRoot(canvas, start, new TreeBlockCanvas.Vec(x, -depth, z)); + } + } + + private static void stilts(TreeBlockCanvas canvas, RootShape shape, int height, RNG rng) { + int stiltTop = Math.min(height - 1, Math.max(2, Math.min(7, (int) Math.round(0.25 * height)))); + List attachments = new ArrayList<>(); + for (TreeBlockCanvas.Vec cell : canvas.getTrunk()) { + if (cell.y() == stiltTop) { + attachments.add(cell); } } - } - - private static void stilts(TreeBlockCanvas canvas, double cx, double cz, double baseRadius, double flare, - int height, int cellCount, RNG rng) { - int stiltTop = Math.max(2, Math.min(7, (int) Math.round(0.25 * height))); - int nProps = Math.max(4, Math.min(10, cellCount + 2)); - for (int p = 0; p < nProps; p++) { - double ang = 2.0 * Math.PI * p / nProps + rng.d(-0.25, 0.25); - double ca = Math.cos(ang); - double sa = Math.sin(ang); - int sx = (int) Math.round(cx + baseRadius * ca); - int sz = (int) Math.round(cz + baseRadius * sa); - int fx = (int) Math.round(cx + flare * ca); - int fz = (int) Math.round(cz + flare * sa); - rasterizeRoot(canvas, sx, stiltTop, sz, fx, -2, fz); + if (attachments.isEmpty()) { + attachments.addAll(shape.cells()); + } + attachments.sort(TreeBlockCanvas.Vec::compareTo); + int props = Math.max(3, Math.min(8, shape.cells().size() + 2)); + for (int prop = 0; prop < props; prop++) { + double angle = 2.0 * Math.PI * prop / props + rng.d(-0.25, 0.25); + double reach = shape.flare() * rng.d(0.8, 1.0); + int x = (int) Math.round(shape.centerX() + reach * Math.cos(angle)); + int z = (int) Math.round(shape.centerZ() + reach * Math.sin(angle)); + int depth = legDepth(Math.min(shape.depth(), 3), prop, props); + TreeBlockCanvas.Vec start = nearestCell(attachments, x, z); + rasterizeRoot(canvas, start, new TreeBlockCanvas.Vec(x, -depth, z)); } } - private static void rasterizeRoot(TreeBlockCanvas canvas, int ox, int oy, int oz, int ex, int ey, int ez) { - int dx = ex - ox; - int dy = ey - oy; - int dz = ez - oz; + private static int legDepth(int depth, int leg, int count) { + return Math.max(1, (int) Math.round(depth * (0.55 + 0.45 * leg / Math.max(1, count - 1)))); + } + + private static TreeBlockCanvas.Vec nearestCell(List cells, double x, double z) { + TreeBlockCanvas.Vec nearest = cells.getFirst(); + double distance = Double.POSITIVE_INFINITY; + for (TreeBlockCanvas.Vec cell : cells) { + double dx = cell.x() - x; + double dz = cell.z() - z; + double candidateDistance = dx * dx + dz * dz; + if (candidateDistance < distance) { + nearest = cell; + distance = candidateDistance; + } + } + return nearest; + } + + private static void rasterizeRoot(TreeBlockCanvas canvas, TreeBlockCanvas.Vec start, TreeBlockCanvas.Vec end) { + int dx = end.x() - start.x(); + int dy = end.y() - start.y(); + int dz = end.z() - start.z(); int steps = Math.max(Math.max(Math.abs(dx), Math.abs(dy)), Math.max(Math.abs(dz), 1)); TreeBlockCanvas.Axis axis = TreeFunctions.logAxis(dx, dy, dz); - for (int i = 0; i <= steps; i++) { + int x = start.x(); + int y = start.y(); + int z = start.z(); + for (int i = 1; i <= steps; i++) { double t = i / (double) steps; - int x = (int) Math.round(ox + dx * t); - int y = (int) Math.round(oy + dy * t); - int z = (int) Math.round(oz + dz * t); - placeRoot(canvas, x, y, z, axis); + int nextX = (int) Math.round(start.x() + dx * t); + int nextY = (int) Math.round(start.y() + dy * t); + int nextZ = (int) Math.round(start.z() + dz * t); + while (y != nextY) { + y += Integer.signum(nextY - y); + placeRoot(canvas, x, y, z, axis); + } + while (x != nextX) { + x += Integer.signum(nextX - x); + placeRoot(canvas, x, y, z, axis); + } + while (z != nextZ) { + z += Integer.signum(nextZ - z); + placeRoot(canvas, x, y, z, axis); + } } } private static void placeRoot(TreeBlockCanvas canvas, int x, int y, int z, TreeBlockCanvas.Axis axis) { - if (!canvas.has(x, y, z)) { + TreeBlockCanvas.Cell cell = canvas.get(x, y, z); + if (cell == null || cell.role() != TreeBlockCanvas.Role.TRUNK && cell.role() != TreeBlockCanvas.Role.SECONDARY_TRUNK) { canvas.setTrunk(x, y, z, TreeBlockCanvas.Role.TRUNK, axis); } } @@ -149,4 +181,8 @@ public final class TreeRootBuilder { private static int autoDepth(int height) { return Math.max(2, Math.min(16, (int) Math.round(0.18 * height))); } + + private record RootShape(List cells, double centerX, double centerZ, + double radius, int depth, double flare) { + } } diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/CaveObjectPlacementTransaction.java b/core/src/main/java/art/arcane/iris/generation/mantle/CaveObjectPlacementTransaction.java index 2138c2769..b53862f49 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/CaveObjectPlacementTransaction.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/CaveObjectPlacementTransaction.java @@ -28,6 +28,7 @@ import art.arcane.iris.structure.object.IObjectPlacer; import art.arcane.iris.generation.block.TileData; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.math.BlockPosition; import art.arcane.iris.generation.block.B; import art.arcane.volmlib.util.collection.KList; import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; @@ -49,6 +50,7 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer { private final Long2IntOpenHashMap surfaceHeights; private final Long2IntOpenHashMap caveCeilings; private int blockWrites; + private BlockPosition canopyPosition; CaveObjectPlacementTransaction(IObjectPlacer delegate, int anchorY, int minDepthBelowSurface) { this.delegate = delegate; @@ -95,13 +97,36 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer { } } - for (BufferedMutation mutation : mutations) { - mutation.apply(delegate); - } + applyMutations(); discard(); return CommitResult.COMMITTED; } + private void applyMutations() { + boolean canopyOpen = false; + boolean skipCanopy = false; + try { + for (BufferedMutation mutation : mutations) { + if (mutation.value() instanceof NativeBlockState) { + if (canopyOpen) { + delegate.endTreeCanopy(); + canopyOpen = false; + } + skipCanopy = mutation.canopy() + && !delegate.beginTreeCanopy(new BlockPosition(mutation.x(), mutation.y(), mutation.z())); + canopyOpen = mutation.canopy() && !skipCanopy; + } + if (!mutation.canopy() || !skipCanopy) { + mutation.apply(delegate); + } + } + } finally { + if (canopyOpen) { + delegate.endTreeCanopy(); + } + } + } + void discard() { mutations.clear(); bufferedBlocks.clear(); @@ -139,11 +164,30 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer { if (state == null) { return; } - mutations.add(new BlockMutation(x, y, z, state)); + mutations.add(new BlockMutation(x, y, z, state, isCanopy(x, y, z))); bufferedBlocks.put(new PositionKey(x, y, z), state); blockWrites++; } + @Override + public boolean beginTreeCanopy(BlockPosition position) { + if (!IObjectPlacer.super.beginTreeCanopy(position)) { + return false; + } + canopyPosition = position; + return true; + } + + @Override + public void endTreeCanopy() { + canopyPosition = null; + } + + private boolean isCanopy(int x, int y, int z) { + return canopyPosition != null && canopyPosition.getX() == x + && canopyPosition.getY() == y && canopyPosition.getZ() == z; + } + @Override public NativeBlockState get(int x, int y, int z) { NativeBlockState buffered = bufferedBlocks.get(new PositionKey(x, y, z)); @@ -198,7 +242,7 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer { @Override public void setTile(int x, int y, int z, TileData tile) { if (tile != null) { - mutations.add(new TileMutation(x, y, z, tile)); + mutations.add(new TileMutation(x, y, z, tile, isCanopy(x, y, z))); } } @@ -207,7 +251,7 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer { if (data == null) { return; } - mutations.add(new DataMutation(x, y, z, data)); + mutations.add(new DataMutation(x, y, z, data, isCanopy(x, y, z))); if (data instanceof NativeBlockState state) { bufferedBlocks.put(new PositionKey(x, y, z), state); blockWrites++; @@ -291,24 +335,26 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer { Object value(); + boolean canopy(); + void apply(IObjectPlacer placer); } - private record BlockMutation(int x, int y, int z, NativeBlockState value) implements BufferedMutation { + private record BlockMutation(int x, int y, int z, NativeBlockState value, boolean canopy) implements BufferedMutation { @Override public void apply(IObjectPlacer placer) { placer.set(x, y, z, value); } } - private record TileMutation(int x, int y, int z, TileData value) implements BufferedMutation { + private record TileMutation(int x, int y, int z, TileData value, boolean canopy) implements BufferedMutation { @Override public void apply(IObjectPlacer placer) { placer.setTile(x, y, z, value); } } - private record DataMutation(int x, int y, int z, Object value) implements BufferedMutation { + private record DataMutation(int x, int y, int z, Object value, boolean canopy) implements BufferedMutation { @Override public void apply(IObjectPlacer placer) { placer.setData(x, y, z, value); diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/FloatingObjectPlacementTransaction.java b/core/src/main/java/art/arcane/iris/generation/mantle/FloatingObjectPlacementTransaction.java index 8551d0fb5..2c3ec96fe 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/FloatingObjectPlacementTransaction.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/FloatingObjectPlacementTransaction.java @@ -24,6 +24,7 @@ import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.structure.object.IObjectPlacer; import art.arcane.iris.generation.block.TileData; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.math.BlockPosition; import art.arcane.iris.generation.block.B; import art.arcane.volmlib.util.collection.KList; import org.jetbrains.annotations.Nullable; @@ -43,6 +44,7 @@ final class FloatingObjectPlacementTransaction implements IObjectPlacer { private final KList mutations; private final Map bufferedBlocks; private int blockWrites; + private BlockPosition canopyPosition; FloatingObjectPlacementTransaction(IObjectPlacer delegate, ObjectContinuationBundle.PlacementKey placementKey) { this.delegate = Objects.requireNonNull(delegate); @@ -70,9 +72,7 @@ final class FloatingObjectPlacementTransaction implements IObjectPlacer { return CommitResult.REJECTED_SUPPORT; } } - for (BufferedMutation mutation : mutations) { - mutation.apply(delegate); - } + applyMutations(); persistContinuation(engine); discard(); return CommitResult.COMMITTED; @@ -94,9 +94,17 @@ final class FloatingObjectPlacementTransaction implements IObjectPlacer { } IrisMatterSupport.ensureRegistered(); LinkedHashMap payloads = new LinkedHashMap<>(); + boolean skipCanopy = false; for (BufferedMutation mutation : mutations) { ObjectContinuationBundle.ChunkPosition chunk = new ObjectContinuationBundle.ChunkPosition(mutation.x() >> 4, mutation.z() >> 4); Matter payload = payloads.computeIfAbsent(chunk, ignored -> new IrisMatter(16, engine.getHeight(), 16)); + if (mutation.value() instanceof NativeBlockState) { + skipCanopy = !ObjectContinuationPersistence.beginBlock(payload, mutation.x() & 15, + mutation.y(), mutation.z() & 15, mutation.canopy()); + } + if (mutation.canopy() && skipCanopy) { + continue; + } ObjectContinuationPersistence.put(payload, mutation.x() & 15, mutation.y(), mutation.z() & 15, mutation.value()); } LinkedHashMap> destinations = new LinkedHashMap<>(); @@ -105,6 +113,31 @@ final class FloatingObjectPlacementTransaction implements IObjectPlacer { new ObjectContinuationBundle.Bounds(minimumX, minimumZ, maximumX, maximumZ), destinations); } + private void applyMutations() { + boolean canopyOpen = false; + boolean skipCanopy = false; + try { + for (BufferedMutation mutation : mutations) { + if (mutation.value() instanceof NativeBlockState) { + if (canopyOpen) { + delegate.endTreeCanopy(); + canopyOpen = false; + } + skipCanopy = mutation.canopy() + && !delegate.beginTreeCanopy(new BlockPosition(mutation.x(), mutation.y(), mutation.z())); + canopyOpen = mutation.canopy() && !skipCanopy; + } + if (!mutation.canopy() || !skipCanopy) { + mutation.apply(delegate); + } + } + } finally { + if (canopyOpen) { + delegate.endTreeCanopy(); + } + } + } + void discard() { mutations.clear(); bufferedBlocks.clear(); @@ -126,11 +159,30 @@ final class FloatingObjectPlacementTransaction implements IObjectPlacer { if (state == null) { return; } - mutations.add(new BlockMutation(x, y, z, state)); + mutations.add(new BlockMutation(x, y, z, state, isCanopy(x, y, z))); bufferedBlocks.put(new PositionKey(x, y, z), state); blockWrites++; } + @Override + public boolean beginTreeCanopy(BlockPosition position) { + if (!IObjectPlacer.super.beginTreeCanopy(position)) { + return false; + } + canopyPosition = position; + return true; + } + + @Override + public void endTreeCanopy() { + canopyPosition = null; + } + + private boolean isCanopy(int x, int y, int z) { + return canopyPosition != null && canopyPosition.getX() == x + && canopyPosition.getY() == y && canopyPosition.getZ() == z; + } + @Override public NativeBlockState get(int x, int y, int z) { NativeBlockState state = bufferedBlocks.get(new PositionKey(x, y, z)); @@ -176,7 +228,7 @@ final class FloatingObjectPlacementTransaction implements IObjectPlacer { @Override public void setTile(int x, int y, int z, TileData tile) { if (tile != null) { - mutations.add(new TileMutation(x, y, z, tile)); + mutations.add(new TileMutation(x, y, z, tile, isCanopy(x, y, z))); } } @@ -185,7 +237,7 @@ final class FloatingObjectPlacementTransaction implements IObjectPlacer { if (data == null) { return; } - mutations.add(new DataMutation(x, y, z, data)); + mutations.add(new DataMutation(x, y, z, data, isCanopy(x, y, z))); if (data instanceof NativeBlockState state) { bufferedBlocks.put(new PositionKey(x, y, z), state); blockWrites++; @@ -228,24 +280,26 @@ final class FloatingObjectPlacementTransaction implements IObjectPlacer { Object value(); + boolean canopy(); + void apply(IObjectPlacer placer); } - private record BlockMutation(int x, int y, int z, NativeBlockState value) implements BufferedMutation { + private record BlockMutation(int x, int y, int z, NativeBlockState value, boolean canopy) implements BufferedMutation { @Override public void apply(IObjectPlacer placer) { placer.set(x, y, z, value); } } - private record TileMutation(int x, int y, int z, TileData value) implements BufferedMutation { + private record TileMutation(int x, int y, int z, TileData value, boolean canopy) implements BufferedMutation { @Override public void apply(IObjectPlacer placer) { placer.setTile(x, y, z, value); } } - private record DataMutation(int x, int y, int z, Object value) implements BufferedMutation { + private record DataMutation(int x, int y, int z, Object value, boolean canopy) implements BufferedMutation { @Override public void apply(IObjectPlacer placer) { placer.setData(x, y, z, value); diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/GoldenDebugObjectPlacer.java b/core/src/main/java/art/arcane/iris/generation/mantle/GoldenDebugObjectPlacer.java index f7987cf74..4ac2ef8bf 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/GoldenDebugObjectPlacer.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/GoldenDebugObjectPlacer.java @@ -24,6 +24,7 @@ import art.arcane.iris.structure.object.IObjectPlacer; import art.arcane.iris.generation.block.TileData; import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.math.BlockPosition; final class GoldenDebugObjectPlacer implements IObjectPlacer { private static final int[] GOLDEN_DEBUG_TARGET = parseGoldenDebugTarget(resolveGoldenDebugSpec()); @@ -93,6 +94,16 @@ final class GoldenDebugObjectPlacer implements IObjectPlacer { delegate.set(x, y, z, d); } + @Override + public boolean beginTreeCanopy(BlockPosition position) { + return delegate.beginTreeCanopy(position); + } + + @Override + public void endTreeCanopy() { + delegate.endTreeCanopy(); + } + @Override public NativeBlockState get(int x, int y, int z) { return delegate.get(x, y, z); diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectContinuationPersistence.java b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectContinuationPersistence.java index 680e06d05..2a588f411 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectContinuationPersistence.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectContinuationPersistence.java @@ -1,9 +1,11 @@ package art.arcane.iris.generation.mantle; import art.arcane.iris.generation.block.TileData; +import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial; import art.arcane.iris.integration.Identifier; import art.arcane.iris.world.storage.matter.TileWrapper; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.hunk.Hunk; import art.arcane.volmlib.util.mantle.flag.MantleFlag; import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; @@ -12,6 +14,7 @@ import art.arcane.volmlib.util.matter.MatterSlice; import java.util.List; import java.util.Map; +import java.util.BitSet; import java.util.function.Supplier; public final class ObjectContinuationPersistence { @@ -68,6 +71,95 @@ public final class ObjectContinuationPersistence { } } + public static boolean beginBlock(Matter payload, int x, int y, int z, boolean canopy) { + if (canopy) { + MatterSlice blocks = payload.getSlice(NativeBlockState.class); + NativeBlockState existing = blocks == null ? null : blocks.get(x, y, z); + if (existing != null && existing.isSolid()) { + return false; + } + payload.slice(TreeCanopy.class).set(x, y, z, TreeCanopy.INSTANCE); + } else { + MatterSlice conditions = payload.getSlice(TreeCanopy.class); + if (conditions != null) { + conditions.set(x, y, z, null); + } + } + return true; + } + + public static void restore(Mantle mantle, int chunkX, int chunkZ, Matter continuation, + Hunk blocks, boolean applyBlocks) { + BitSet blocked = blockedCanopy(continuation, blocks, applyBlocks); + MatterSlice states = continuation.getSlice(NativeBlockState.class); + MantleChunk chunk = mantle.useChunk(chunkX, chunkZ); + try { + synchronized (chunk) { + if (states != null) { + states.iterateSync((x, y, z, state) -> { + if (blocked.get(cellIndex(x, y, z))) { + return; + } + Matter section = chunk.get(y >> 4); + if (section != null) { + clearMetadata(section, x, y & 15, z); + } + }); + } + for (Map.Entry, MatterSlice> entry : continuation.getSliceMap().entrySet()) { + if (entry.getKey() == TreeCanopy.class) { + continue; + } + entry.getValue().iterateSync((x, y, z, value) -> { + if (!blocked.get(cellIndex(x, y, z))) { + chunk.getOrCreate(y >> 4).slice(entry.getKey()).set(x, y & 15, z, value); + } + }); + } + } + } finally { + chunk.release(); + } + if (applyBlocks && states != null) { + states.iterateSync((x, y, z, state) -> { + if (!blocked.get(cellIndex(x, y, z))) { + blocks.set(x, y, z, state); + } + }); + } + } + + private static BitSet blockedCanopy(Matter continuation, Hunk blocks, boolean applyBlocks) { + BitSet blocked = new BitSet(); + MatterSlice conditions = continuation.getSlice(TreeCanopy.class); + if (conditions != null) { + conditions.iterateSync((x, y, z, condition) -> { + NativeBlockState existing = blocks.get(x, y, z); + if (!applyBlocks || existing != null && existing.isSolid()) { + blocked.set(cellIndex(x, y, z)); + } + }); + } + return blocked; + } + + private static int cellIndex(int x, int y, int z) { + return (y << 8) | (z << 4) | x; + } + + private static void clearMetadata(Matter section, int x, int y, int z) { + clearMetadata(section.getSlice(Identifier.class), x, y, z); + clearMetadata(section.getSlice(String.class), x, y, z); + clearMetadata(section.getSlice(TreeBlockMaterial.class), x, y, z); + clearMetadata(section.getSlice(TileWrapper.class), x, y, z); + } + + private static void clearMetadata(MatterSlice slice, int x, int y, int z) { + if (slice != null) { + slice.set(x, y, z, null); + } + } + private static boolean contains(ObjectContinuationBundle bundle, ObjectContinuationBundle.PlacementKey key) { if (bundle == null) { return false; diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java index a6970a7de..f1012ec84 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java @@ -10,6 +10,7 @@ import art.arcane.iris.generation.subterrain.SubterrainRasterizer; import art.arcane.iris.generation.block.TileData; import art.arcane.iris.structure.object.IObjectPlacer; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.math.BlockPosition; import art.arcane.iris.generation.block.B; import art.arcane.iris.world.storage.matter.TileWrapper; import art.arcane.volmlib.util.matter.MatterCavern; @@ -34,6 +35,7 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { private final List mutations; private final List placements = new ArrayList<>(); private final Long2ObjectOpenHashMap> overlay; + private BlockPosition canopyPosition; private boolean hydrologyGateKnown; private int hydrologyGateChunkX; private int hydrologyGateChunkZ; @@ -82,7 +84,15 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { } void apply(ObjectSourcePlan plan) { + boolean skipCanopy = false; for (Mutation mutation : plan.mutationsFor(destinationChunkX, destinationChunkZ)) { + if (mutation.key().type() == NativeBlockState.class) { + NativeBlockState existing = mutation.canopy() ? get(mutation.key().x(), mutation.key().y(), mutation.key().z()) : null; + skipCanopy = existing != null && existing.isSolid(); + } + if (mutation.canopy() && skipCanopy) { + continue; + } switch (mutation) { case SetMutation set -> setData(set.key().x(), set.key().y(), set.key().z(), set.value(), set); case CustomBlockMutation custom -> set(custom.key().x(), custom.key().y(), custom.key().z(), custom.state(), custom); @@ -158,12 +168,31 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { cell.tile = CLEARED; cell.identifier = identifier; mutations.add(replayed != null - ? replayed : new CustomBlockMutation(new DataKey(x, y, z, NativeBlockState.class), state)); + ? replayed : new CustomBlockMutation(new DataKey(x, y, z, NativeBlockState.class), state, isCanopy(x, y, z))); return; } setData(x, y, z, state); } + @Override + public boolean beginTreeCanopy(BlockPosition position) { + if (!ObjectPassPlacer.super.beginTreeCanopy(position)) { + return false; + } + canopyPosition = position; + return true; + } + + @Override + public void endTreeCanopy() { + canopyPosition = null; + } + + private boolean isCanopy(int x, int y, int z) { + return canopyPosition != null && canopyPosition.getX() == x + && canopyPosition.getY() == y && canopyPosition.getZ() == z; + } + @Override public NativeBlockState get(int x, int y, int z) { OverlayCell cell = overlayCell(x, y, z); @@ -257,7 +286,7 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { cell.put(type, data); } mutations.add(replayed != null - ? replayed : new SetMutation(new DataKey(x, y, z, type), data)); + ? replayed : new SetMutation(new DataKey(x, y, z, type), data, isCanopy(x, y, z))); } @Override @@ -476,10 +505,12 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { DataKey key(); + boolean canopy(); + void apply(IObjectPlacer placer); } - record SetMutation(DataKey key, Object value) implements Mutation { + record SetMutation(DataKey key, Object value, boolean canopy) implements Mutation { @Override public int x() { return key.x(); @@ -496,7 +527,7 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { } } - record CustomBlockMutation(DataKey key, NativeBlockState state) implements Mutation { + record CustomBlockMutation(DataKey key, NativeBlockState state, boolean canopy) implements Mutation { @Override public int x() { return key.x(); diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectSourcePlan.java b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectSourcePlan.java index c90372d47..236c1b90c 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectSourcePlan.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectSourcePlan.java @@ -32,7 +32,8 @@ final class ObjectSourcePlan { private static final int PLACEMENT_DESTINATION_BYTES = 40; private static final int UNKNOWN_PAYLOAD_BYTES = 256; private static final int CUSTOM_BIT = 1 << 8; - private static final int Y_SHIFT = 9; + private static final int CANOPY_BIT = 1 << 9; + private static final int Y_SHIFT = 10; private static final int MAXIMUM_Y = (1 << (31 - Y_SHIFT)) - 1; private final long[] destinations; @@ -102,7 +103,7 @@ final class ObjectSourcePlan { custom = CUSTOM_BIT; } } - cells[position] = (key.y() << Y_SHIFT) | custom | ((key.z() & 15) << 4) | (key.x() & 15); + cells[position] = (key.y() << Y_SHIFT) | custom | (mutation.canopy() ? CANOPY_BIT : 0) | ((key.z() & 15) << 4) | (key.x() & 15); valueIndexes[position] = table.indexOf(value); } values = table.values.toArray(); @@ -275,9 +276,9 @@ final class ObjectSourcePlan { Object value = values[valueIndexes[start + index]]; decoded[index] = (cell & CUSTOM_BIT) != 0 ? new ObjectDestinationTransaction.CustomBlockMutation( - new ObjectDestinationTransaction.DataKey(x, y, z, NativeBlockState.class), (NativeBlockState) value) + new ObjectDestinationTransaction.DataKey(x, y, z, NativeBlockState.class), (NativeBlockState) value, (cell & CANOPY_BIT) != 0) : new ObjectDestinationTransaction.SetMutation( - new ObjectDestinationTransaction.DataKey(x, y, z, typeOf(value)), value); + new ObjectDestinationTransaction.DataKey(x, y, z, typeOf(value)), value, (cell & CANOPY_BIT) != 0); } return List.of(decoded); } @@ -285,10 +286,17 @@ final class ObjectSourcePlan { static byte[] encode(List mutations, int height) { IrisMatterSupport.ensureRegistered(); Matter payload = new IrisMatter(16, height, 16); + boolean skipCanopy = false; for (ObjectDestinationTransaction.Mutation mutation : mutations) { ObjectDestinationTransaction.DataKey key = mutation.key(); int x = key.x() & 15; int z = key.z() & 15; + if (key.type() == NativeBlockState.class) { + skipCanopy = !ObjectContinuationPersistence.beginBlock(payload, x, key.y(), z, mutation.canopy()); + } + if (mutation.canopy() && skipCanopy) { + continue; + } if (mutation instanceof ObjectDestinationTransaction.CustomBlockMutation custom) { payload.slice(NativeBlockState.class).set(x, key.y(), z, custom.state().placementBaseState()); payload.slice(Identifier.class).set(x, key.y(), z, Identifier.fromString(custom.state().deferredPlacementKey())); diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/TreeCanopy.java b/core/src/main/java/art/arcane/iris/generation/mantle/TreeCanopy.java new file mode 100644 index 000000000..e3270c6f8 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/mantle/TreeCanopy.java @@ -0,0 +1,5 @@ +package art.arcane.iris.generation.mantle; + +public enum TreeCanopy { + INSTANCE +} diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/EngineMode.java b/core/src/main/java/art/arcane/iris/generation/runtime/EngineMode.java index 37f709620..0c32a4e3e 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/EngineMode.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/EngineMode.java @@ -23,11 +23,9 @@ import art.arcane.iris.generation.concurrent.MultiBurst; import art.arcane.iris.generation.context.ChunkContext; import art.arcane.iris.generation.context.IrisContext; import art.arcane.iris.generation.mantle.EngineMantle; +import art.arcane.iris.generation.mantle.ObjectContinuationPersistence; import art.arcane.iris.generation.stage.IrisDimensionStackActuator; import art.arcane.iris.generation.stage.IrisTransitionGeometryActuator; -import art.arcane.iris.integration.Identifier; -import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial; -import art.arcane.iris.world.storage.matter.TileWrapper; import art.arcane.iris.world.history.FloatingBiomeOverlay; import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; import art.arcane.iris.world.history.SavedTerrainChunk; @@ -36,13 +34,10 @@ import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.documentation.BlockCoordinates; import art.arcane.volmlib.util.hunk.Hunk; -import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.matter.Matter; -import art.arcane.volmlib.util.matter.MatterSlice; import java.io.IOException; import java.util.List; -import java.util.Map; public interface EngineMode extends Staged { KList getTerrainStages(); @@ -221,7 +216,7 @@ public interface EngineMode extends Staged { recordNaturalTerrain(x, z, blocks, biomes, ctx); IrisDimensionStackActuator.clearHostNaturalMetadata(getEngine(), x, z, biomes.getHeight(), ctx); ctx.beginContent(); - List continuations = restoreObjectContinuations(x, z); + List continuations = restoreObjectContinuations(x, z, blocks); if (!continuations.isEmpty()) { ctx.setCaveTerrain(null); } @@ -229,16 +224,13 @@ public interface EngineMode extends Staged { i.generate(x, z, blocks, biomes, multicore, ctx); } for (Matter continuation : continuations) { - restoreObjectMetadata(x, z, continuation); - MatterSlice states = continuation.getSlice(NativeBlockState.class); - if (states != null) { - states.iterateSync((localX, y, localZ, state) -> blocks.set(localX, y, localZ, state)); - } + ObjectContinuationPersistence.restore(getMantle().getMantle(), x >> 4, z >> 4, + continuation, blocks, true); } } } - private List restoreObjectContinuations(int x, int z) { + private List restoreObjectContinuations(int x, int z, Hunk blocks) { if (!(getEngine() instanceof IrisEngine irisEngine)) { return List.of(); } @@ -257,7 +249,8 @@ public interface EngineMode extends Staged { if (continuation.getHeight() != getEngine().getHeight()) { throw new IOException("Object continuation height does not match the world"); } - restoreObjectMetadata(x, z, continuation); + ObjectContinuationPersistence.restore(getMantle().getMantle(), x >> 4, z >> 4, + continuation, blocks, false); } return continuations; } catch (IOException failure) { @@ -265,42 +258,6 @@ public interface EngineMode extends Staged { } } - private void restoreObjectMetadata(int x, int z, Matter continuation) { - MantleChunk chunk = getMantle().getMantle().useChunk(x >> 4, z >> 4); - try { - synchronized (chunk) { - MatterSlice states = continuation.getSlice(NativeBlockState.class); - if (states != null) { - states.iterateSync((localX, y, localZ, state) -> { - Matter section = chunk.get(y >> 4); - if (section != null) { - clearObjectMetadata(section, localX, y & 15, localZ); - } - }); - } - for (Map.Entry, MatterSlice> entry : continuation.getSliceMap().entrySet()) { - entry.getValue().iterateSync((localX, y, localZ, value) -> - chunk.getOrCreate(y >> 4).slice(entry.getKey()).set(localX, y & 15, localZ, value)); - } - } - } finally { - chunk.release(); - } - } - - private static void clearObjectMetadata(Matter section, int x, int y, int z) { - clearObjectMetadata(section.getSlice(Identifier.class), x, y, z); - clearObjectMetadata(section.getSlice(String.class), x, y, z); - clearObjectMetadata(section.getSlice(TreeBlockMaterial.class), x, y, z); - clearObjectMetadata(section.getSlice(TileWrapper.class), x, y, z); - } - - private static void clearObjectMetadata(MatterSlice slice, int x, int y, int z) { - if (slice != null) { - slice.set(x, y, z, null); - } - } - private void recordNaturalTerrain(int x, int z, Hunk blocks, Hunk biomes, ChunkContext context) { if (!(getEngine() instanceof IrisEngine irisEngine)) { diff --git a/core/src/main/java/art/arcane/iris/structure/object/DecayControlPlacer.java b/core/src/main/java/art/arcane/iris/structure/object/DecayControlPlacer.java index 102b3e3df..4ca302fe4 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/DecayControlPlacer.java +++ b/core/src/main/java/art/arcane/iris/structure/object/DecayControlPlacer.java @@ -23,6 +23,7 @@ import art.arcane.iris.generation.block.TileData; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.generation.runtime.Engine; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.math.BlockPosition; import org.jetbrains.annotations.Nullable; public class DecayControlPlacer implements IObjectPlacer { @@ -47,6 +48,16 @@ public class DecayControlPlacer implements IObjectPlacer { delegate.set(x, y, z, d); } + @Override + public boolean beginTreeCanopy(BlockPosition position) { + return delegate.beginTreeCanopy(position); + } + + @Override + public void endTreeCanopy() { + delegate.endTreeCanopy(); + } + @Override public NativeBlockState get(int x, int y, int z) { return delegate.get(x, y, z); diff --git a/core/src/main/java/art/arcane/iris/structure/object/HeightmapObjectPlacer.java b/core/src/main/java/art/arcane/iris/structure/object/HeightmapObjectPlacer.java index e4ad06f65..e463d4b92 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/HeightmapObjectPlacer.java +++ b/core/src/main/java/art/arcane/iris/structure/object/HeightmapObjectPlacer.java @@ -22,6 +22,7 @@ import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.block.TileData; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.math.BlockPosition; import art.arcane.volmlib.util.math.RNG; public class HeightmapObjectPlacer implements IObjectPlacer { @@ -47,6 +48,16 @@ public class HeightmapObjectPlacer implements IObjectPlacer { oplacer.set(param1Int1, param1Int2, param1Int3, param1BlockData); } + @Override + public boolean beginTreeCanopy(BlockPosition position) { + return oplacer.beginTreeCanopy(position); + } + + @Override + public void endTreeCanopy() { + oplacer.endTreeCanopy(); + } + public NativeBlockState get(int param1Int1, int param1Int2, int param1Int3) { return oplacer.get(param1Int1, param1Int2, param1Int3); } diff --git a/core/src/main/java/art/arcane/iris/structure/object/IObjectPlacer.java b/core/src/main/java/art/arcane/iris/structure/object/IObjectPlacer.java index 53861d1d1..170eef8d4 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/IObjectPlacer.java +++ b/core/src/main/java/art/arcane/iris/structure/object/IObjectPlacer.java @@ -24,6 +24,7 @@ import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.generation.runtime.Engine; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.math.BlockPosition; import org.jetbrains.annotations.Nullable; public interface IObjectPlacer { @@ -35,6 +36,14 @@ public interface IObjectPlacer { NativeBlockState get(int x, int y, int z); + default boolean beginTreeCanopy(BlockPosition position) { + NativeBlockState existing = get(position.getX(), position.getY(), position.getZ()); + return existing == null || !existing.isSolid(); + } + + default void endTreeCanopy() { + } + boolean isPreventingDecay(); boolean isCarved(int x, int y, int z); diff --git a/core/src/main/java/art/arcane/iris/structure/object/IrisObject.java b/core/src/main/java/art/arcane/iris/structure/object/IrisObject.java index d0fb4e650..239bb6e3a 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/IrisObject.java +++ b/core/src/main/java/art/arcane/iris/structure/object/IrisObject.java @@ -20,6 +20,7 @@ package art.arcane.iris.structure.object; import art.arcane.iris.structure.object.FloatingObjectFootprint; import art.arcane.iris.generation.block.TileData; +import art.arcane.iris.generation.decoration.tree.TreeGrounding; import art.arcane.iris.generation.cave.CarveResult; import art.arcane.iris.pack.value.IrisPosition; @@ -144,6 +145,9 @@ public class IrisObject extends IrisRegistrant { transient final AtomicCache> surfaceSupportOffsets = new AtomicCache<>(); transient final AtomicCache floatingFootprint = new AtomicCache<>(); @Getter + @Setter + private transient TreeGrounding treeGrounding; + @Getter VectorMap blocks; @Getter VectorMap states; @@ -203,6 +207,7 @@ public class IrisObject extends IrisRegistrant { copy.setLoader(getLoader()); copy.setLoadFile(getLoadFile()); copy.setCenter(getCenter().clone()); + copy.setTreeGrounding(treeGrounding); blocks.forEach(copy.blocks::put); states.forEach((position, tile) -> copy.states.put(position, tile.clone())); @@ -349,6 +354,9 @@ public class IrisObject extends IrisRegistrant { KList getSurfaceSupportOffsets() { return surfaceSupportOffsets.aquire(() -> { + if (treeGrounding != null) { + return treeGrounding.supportOffsets(); + } readLock.lock(); try { int lowestY = Integer.MAX_VALUE; diff --git a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectPlacementRunner.java b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectPlacementRunner.java index 1819361ea..06f295b60 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectPlacementRunner.java +++ b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectPlacementRunner.java @@ -23,6 +23,7 @@ import art.arcane.iris.generation.block.TileData; import art.arcane.iris.generation.cave.CarveResult; import art.arcane.iris.generation.cave.CarvingMode; import art.arcane.iris.generation.decoration.IrisProceduralBlocks; +import art.arcane.iris.generation.decoration.tree.TreeGrounding; import art.arcane.iris.generation.decoration.IrisStiltSettings; import art.arcane.iris.structure.object.IrisSurfaceSupport; import art.arcane.iris.generation.decoration.IrisVacuumSettings; @@ -331,6 +332,18 @@ final class IrisObjectPlacementRunner { return -1; } + TreeGrounding.Plan grounding = null; + if (self.getTreeGrounding() != null && yv < 0 && config.getCarvingSupport().supportsSurface() + && !config.isForcePlace() && !config.isFromBottom() && !paint && !ceilingHang + && config.getMode() != ObjectPlaceMode.FLOATING && !rawStructurePiece) { + grounding = self.getTreeGrounding().plan(placer, new TreeGrounding.Placement(rdata, config, + new BlockPosition(x, y + yrand, z), new BlockPosition(spinx, spiny, spinz))); + if (!grounding.supported()) { + return -1; + } + stilting = false; + } + IrisPaintSurfaceProjection paintProjection = null; KList paintSupport = null; if (paint) { @@ -467,6 +480,7 @@ final class IrisObjectPlacementRunner { try { VectorMap blocks = self.blocks; VectorMap states = self.states; + TreeGrounding tree = self.getTreeGrounding(); // Zero-tile objects are the common case: skip the two Key allocations VectorMap#get needs to prove null. boolean hasStates = !states.isEmpty(); @@ -622,42 +636,53 @@ final class IrisObjectPlacementRunner { continue; } - data = normalizePlacedWaterlogging(data, placer, xx, yy, zz, waterlogCandidate); - - if (!rawStructurePiece && B.isVineBlock(data)) { - data = attachVineFaces(placer, data, xx, yy, zz); + boolean canopy = tree != null && tree.isCanopy(g.getBlockX(), g.getBlockY(), g.getBlockZ()); + if (canopy && !placer.beginTreeCanopy(new BlockPosition(xx, yy, zz))) { + continue; } - // Short-circuit order matters for cost only: the mantle read is paid solely - // for vine blocks. Both operands are pure, so the value is unchanged. - boolean wouldReplace = !rawStructurePiece && B.isVineBlock(data) && B.isSolid(placer.get(xx, yy, zz)); - String material = IrisObjectShaping.materialKey(data); - boolean air = material.equals("minecraft:air") || material.equals("minecraft:cave_air"); - boolean place = shouldPlaceObjectBlock(rawStructurePiece, air, wouldReplace); + try { + data = normalizePlacedWaterlogging(data, placer, xx, yy, zz, waterlogCandidate); - if (data.isCustom() || place) { - placer.set(xx, yy, zz, data); - if (heightmap != null) { - Position2 pos = new Position2(xx, zz); - Integer currentHeight = heightmap.get(pos); - if (currentHeight == null || currentHeight < yy) { - heightmap.put(pos, yy); + if (!rawStructurePiece && B.isVineBlock(data)) { + data = attachVineFaces(placer, data, xx, yy, zz); + } + + // Short-circuit order matters for cost only: the mantle read is paid solely + // for vine blocks. Both operands are pure, so the value is unchanged. + boolean wouldReplace = !rawStructurePiece && B.isVineBlock(data) && B.isSolid(placer.get(xx, yy, zz)); + String material = IrisObjectShaping.materialKey(data); + boolean air = material.equals("minecraft:air") || material.equals("minecraft:cave_air"); + boolean place = shouldPlaceObjectBlock(rawStructurePiece, air, wouldReplace); + + if (data.isCustom() || place) { + placer.set(xx, yy, zz, data); + if (heightmap != null) { + Position2 pos = new Position2(xx, zz); + Integer currentHeight = heightmap.get(pos); + if (currentHeight == null || currentHeight < yy) { + heightmap.put(pos, yy); + } + } + if (tile != null) { + placer.setTile(xx, yy, zz, tile); + } + if (markers != null && markers.containsKey(g)) { + placer.setData(xx, yy, zz, new MatterMarker(markers.get(g))); + } + if (listener != null) { + listener.accept(new BlockPosition(xx, yy, zz), data); + } + if (vacuuming && yy < vacuumLowest) { + vacuumLowest = yy; + } + if (vacuuming && yy > vacuumHighest) { + vacuumHighest = yy; } } - if (tile != null) { - placer.setTile(xx, yy, zz, tile); - } - if (markers != null && markers.containsKey(g)) { - placer.setData(xx, yy, zz, new MatterMarker(markers.get(g))); - } - if (listener != null) { - listener.accept(new BlockPosition(xx, yy, zz), data); - } - if (vacuuming && yy < vacuumLowest) { - vacuumLowest = yy; - } - if (vacuuming && yy > vacuumHighest) { - vacuumHighest = yy; + } finally { + if (canopy) { + placer.endTreeCanopy(); } } } @@ -665,6 +690,19 @@ final class IrisObjectPlacementRunner { self.readLock.unlock(); } + if (grounding != null) { + for (TreeGrounding.Extension extension : grounding.extensions()) { + for (int rootY = extension.topY(); rootY >= extension.bottomY(); rootY--) { + NativeBlockState rootState = normalizePlacedWaterlogging(extension.state(), placer, + extension.x(), rootY, extension.z(), waterlogCandidate); + placer.set(extension.x(), rootY, extension.z(), rootState); + if (listener != null) { + listener.accept(new BlockPosition(extension.x(), rootY, extension.z()), rootState); + } + } + } + } + if (stilting) { self.readLock.lock(); try { diff --git a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectShaping.java b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectShaping.java index 9a3dc9d6a..17c82686c 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectShaping.java +++ b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectShaping.java @@ -20,6 +20,8 @@ package art.arcane.iris.structure.object; import art.arcane.iris.generation.block.TileData; import art.arcane.iris.generation.decoration.IrisProceduralBlocks; +import art.arcane.iris.generation.decoration.tree.TreeBlockCanvas; +import art.arcane.iris.generation.decoration.tree.TreeGrounding; import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; @@ -29,6 +31,9 @@ import art.arcane.volmlib.util.math.Vector3i; import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; import java.util.Set; /** @@ -264,6 +269,19 @@ final class IrisObjectShaping { self.shrinkOffset = offset; self.blocks = b; self.states = s; + if (self.getTreeGrounding() != null) { + List roots = new ArrayList<>(); + for (TreeGrounding.Root root : self.getTreeGrounding().roots()) { + roots.add(new TreeGrounding.Root(root.x() + offset.getX(), root.y() + offset.getY(), + root.z() + offset.getZ(), root.state())); + } + Set canopy = new HashSet<>(); + for (TreeBlockCanvas.Vec leaf : self.getTreeGrounding().canopy()) { + canopy.add(new TreeBlockCanvas.Vec(leaf.x() + offset.getX(), leaf.y() + offset.getY(), + leaf.z() + offset.getZ())); + } + self.setTreeGrounding(TreeGrounding.create(roots, self.getTreeGrounding().maxExtension(), canopy)); + } self.surfaceSupportOffsets.reset(); self.floatingFootprint.reset(); } diff --git a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectTransforms.java b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectTransforms.java index ebd07b42c..d44c74a18 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectTransforms.java +++ b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectTransforms.java @@ -23,6 +23,8 @@ import art.arcane.iris.pack.value.IrisPosition; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.iris.generation.block.B; +import art.arcane.iris.generation.decoration.tree.TreeBlockCanvas; +import art.arcane.iris.generation.decoration.tree.TreeGrounding; import art.arcane.iris.generation.block.VectorMap; import art.arcane.iris.generation.geometry.AxisAlignedBB; import art.arcane.iris.generation.geometry.IrisBlockVector; @@ -33,10 +35,12 @@ import art.arcane.volmlib.util.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. @@ -56,6 +60,8 @@ final class IrisObjectTransforms { try { VectorMap d = new VectorMap<>(); VectorMap dx = new VectorMap<>(); + TreeGrounding grounding = self.getTreeGrounding(); + Set canopy = grounding == null ? null : new HashSet<>(); for (Map.Entry entry : self.blocks) { NativeBlockState rotated = r.rotate(entry.getValue(), spinx, spiny, spinz); @@ -64,6 +70,16 @@ final class IrisObjectTransforms { } IrisBlockVector destination = r.rotate(entry.getKey(), spinx, spiny, spinz); d.put(destination, rotated); + if (canopy != null) { + TreeBlockCanvas.Vec cell = new TreeBlockCanvas.Vec(destination.getBlockX(), + destination.getBlockY(), destination.getBlockZ()); + IrisBlockVector source = entry.getKey(); + if (grounding.isCanopy(source.getBlockX(), source.getBlockY(), source.getBlockZ())) { + canopy.add(cell); + } else { + canopy.remove(cell); + } + } TileData tile = self.states.get(entry.getKey()); if (tile == null) { dx.remove(destination); @@ -74,6 +90,17 @@ final class IrisObjectTransforms { self.blocks = d; self.states = dx; + if (grounding != null) { + List roots = new ArrayList<>(); + for (TreeGrounding.Root root : grounding.roots()) { + IrisBlockVector position = r.rotate(new IrisBlockVector(root.x(), root.y(), root.z()), spinx, spiny, spinz); + NativeBlockState state = d.get(position); + if (state != null) { + roots.add(new TreeGrounding.Root(position.getBlockX(), position.getBlockY(), position.getBlockZ(), state)); + } + } + self.setTreeGrounding(TreeGrounding.create(roots, grounding.maxExtension(), canopy)); + } IrisObjectShaping.shrinkwrap(self); self.surfaceSupportOffsets.reset(); self.floatingFootprint.reset(); @@ -99,6 +126,8 @@ final class IrisObjectTransforms { boolean savedOrigin = origin == ScaleOrigin.SAVED; List voxels; IrisObject output; + TreeGrounding grounding; + Vector3i sourceCenter; self.readLock.lock(); try { output = savedOrigin ? createOriginScaledObject(self, scale) @@ -107,7 +136,8 @@ final class IrisObjectTransforms { output.setLoader(self.getLoader()); output.setLoadFile(self.getLoadFile()); voxels = new ArrayList<>(self.blocks.size()); - Vector3i sourceCenter = self.getCenter(); + sourceCenter = self.getCenter().clone(); + grounding = self.getTreeGrounding(); IrisBlockVector targetCenter = new IrisBlockVector(output.getCenter().getX(), output.getCenter().getY(), output.getCenter().getZ()); for (Map.Entry entry : self.blocks) { @@ -124,7 +154,8 @@ final class IrisObjectTransforms { } TileData tile = self.states.get(sourcePosition); voxels.add(new ScaledVoxel(sourcePosition.clone(), minimum, maximum, entry.getValue(), - tile == null ? null : tile.clone())); + tile == null ? null : tile.clone(), grounding != null && grounding.isCanopy( + sourcePosition.getBlockX(), sourcePosition.getBlockY(), sourcePosition.getBlockZ()))); } } finally { self.readLock.unlock(); @@ -134,8 +165,9 @@ final class IrisObjectTransforms { .thenComparingInt(voxel -> voxel.source().getBlockY()) .thenComparingInt(voxel -> voxel.source().getBlockZ())); } + Set canopy = grounding == null ? null : new HashSet<>(); for (ScaledVoxel voxel : voxels) { - writeScaledVoxel(output, voxel); + writeScaledVoxel(output, voxel, canopy); } VectorMap scaledBlocks = output.blocks; @@ -147,10 +179,43 @@ final class IrisObjectTransforms { } } removeInapplicableTiles(output, scaledBlocks); + if (grounding != null) { + canopy.removeIf(cell -> !output.blocks.containsKey(new IrisBlockVector(cell.x(), cell.y(), cell.z()))); + scaleRoots(grounding, sourceCenter, output, scale, savedOrigin, canopy); + } return output; } - private static void writeScaledVoxel(IrisObject output, ScaledVoxel voxel) { + private static void scaleRoots(TreeGrounding grounding, Vector3i sourceCenter, IrisObject output, + double scale, boolean savedOrigin, Set canopy) { + List roots = new ArrayList<>(); + Vector3i targetCenter = output.getCenter(); + for (TreeGrounding.Root root : grounding.roots()) { + IrisBlockVector position = new IrisBlockVector(root.x(), root.y(), root.z()); + if (!savedOrigin) { + position.add(new IrisBlockVector(sourceCenter.getX(), sourceCenter.getY(), sourceCenter.getZ())); + } + IrisBlockVector minimum = scaledMinimum(position, scale); + IrisBlockVector maximum = scaledMaximum(position, scale); + if (!savedOrigin) { + IrisBlockVector offset = new IrisBlockVector(targetCenter.getX(), targetCenter.getY(), targetCenter.getZ()); + minimum.subtract(offset); + maximum.subtract(offset); + } + int y = minimum.getBlockY(); + for (int x = minimum.getBlockX(); x <= maximum.getBlockX(); x++) { + for (int z = minimum.getBlockZ(); z <= maximum.getBlockZ(); z++) { + NativeBlockState state = output.blocks.get(new IrisBlockVector(x, y, z)); + if (state != null) { + roots.add(new TreeGrounding.Root(x, y, z, state)); + } + } + } + } + output.setTreeGrounding(TreeGrounding.create(roots, (int) Math.ceil(grounding.maxExtension() * scale), canopy)); + } + + private static void writeScaledVoxel(IrisObject output, ScaledVoxel voxel, Set canopy) { IrisBlockVector minimum = voxel.minimum(); IrisBlockVector maximum = voxel.maximum(); IrisBlockVector position = minimum.clone(); @@ -161,6 +226,14 @@ final class IrisObjectTransforms { for (int y = minimum.getBlockY(); y <= maximum.getBlockY(); y++) { position.setY(y); output.blocks.put(position, voxel.block()); + if (canopy != null) { + TreeBlockCanvas.Vec cell = new TreeBlockCanvas.Vec(x, y, z); + if (voxel.canopy()) { + canopy.add(cell); + } else { + canopy.remove(cell); + } + } if (voxel.tile() == null) { output.states.remove(position); } else { @@ -468,7 +541,7 @@ final class IrisObjectTransforms { } private record ScaledVoxel(IrisBlockVector source, IrisBlockVector minimum, IrisBlockVector maximum, - NativeBlockState block, TileData tile) { + NativeBlockState block, TileData tile, boolean canopy) { } private enum ScaleOrigin { diff --git a/core/src/main/java/art/arcane/iris/world/storage/matter/IrisMatterSupport.java b/core/src/main/java/art/arcane/iris/world/storage/matter/IrisMatterSupport.java index a67a35db7..d3683f8ae 100644 --- a/core/src/main/java/art/arcane/iris/world/storage/matter/IrisMatterSupport.java +++ b/core/src/main/java/art/arcane/iris/world/storage/matter/IrisMatterSupport.java @@ -42,6 +42,7 @@ public final class IrisMatterSupport { IrisMatter.registerSliceType(new EntityMatter()); IrisMatter.registerSliceType(new CustomBlockOwnershipMatter()); IrisMatter.registerSliceType(new ObjectContinuationMatter()); + IrisMatter.registerSliceType(new TreeCanopyMatter()); IrisMatter.registerSliceType("art.arcane.iris.core.link.Identifier", new IdentifierMatter()); IrisMatter.registerSliceType("art.arcane.iris.engine.framework.NativeStructureOwnershipBundle", new NativeStructureOwnershipMatter()); IrisMatter.registerSliceType(new PlatformBlockMatter()); diff --git a/core/src/main/java/art/arcane/iris/world/storage/matter/TreeCanopyMatter.java b/core/src/main/java/art/arcane/iris/world/storage/matter/TreeCanopyMatter.java new file mode 100644 index 000000000..1abf79d15 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/world/storage/matter/TreeCanopyMatter.java @@ -0,0 +1,39 @@ +package art.arcane.iris.world.storage.matter; + +import art.arcane.iris.generation.mantle.TreeCanopy; +import art.arcane.volmlib.util.data.palette.Palette; +import art.arcane.volmlib.util.matter.Sliced; +import art.arcane.volmlib.util.matter.slices.RawMatter; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; + +@Sliced +public final class TreeCanopyMatter extends RawMatter { + public TreeCanopyMatter() { + this(1, 1, 1); + } + + public TreeCanopyMatter(int width, int height, int depth) { + super(width, height, depth, TreeCanopy.class); + } + + @Override + public Palette getGlobalPalette() { + return null; + } + + @Override + public void writeNode(TreeCanopy canopy, DataOutputStream output) throws IOException { + output.writeByte(1); + } + + @Override + public TreeCanopy readNode(DataInputStream input) throws IOException { + if (input.readUnsignedByte() != 1) { + throw new IOException("Invalid tree canopy placement condition"); + } + return TreeCanopy.INSTANCE; + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/decoration/tree/TreeCanopyBuilderTest.java b/core/src/test/java/art/arcane/iris/generation/decoration/tree/TreeCanopyBuilderTest.java new file mode 100644 index 000000000..6d17bdc4e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/decoration/tree/TreeCanopyBuilderTest.java @@ -0,0 +1,62 @@ +package art.arcane.iris.generation.decoration.tree; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class TreeCanopyBuilderTest { + @Test + public void laterBranchesGrowThroughEarlierLeafClusters() { + IrisProceduralTree tree = branchedTree(); + TreeBlockCanvas canvas = new TreeBlockCanvas(); + double[][] offsets = TreeTrunkBuilder.build(canvas, tree, 6).getFirst().offsets(); + + TreeCanopyBuilder.build(canvas, tree, 6, offsets, 0, 42L, null); + + for (int y = 0; y < 6; y++) { + for (int z = 0; z <= 4; z++) { + assertTrue(canvas.getTrunk().contains(new TreeBlockCanvas.Vec(0, y, z))); + assertFalse(canvas.getLeaf().contains(new TreeBlockCanvas.Vec(0, y, z))); + } + } + } + + @Test + public void branchWoodReplacesBothLeafRolesButKeepsWoodAndAccents() { + IrisProceduralTree tree = branchedTree(); + TreeBlockCanvas canvas = new TreeBlockCanvas(); + double[][] offsets = TreeTrunkBuilder.build(canvas, tree, 6).getFirst().offsets(); + canvas.setLeaf(0, 1, 1, TreeBlockCanvas.Role.LEAF); + canvas.setLeaf(0, 1, 2, TreeBlockCanvas.Role.SECONDARY_LEAF); + canvas.setTrunk(0, 1, 3, TreeBlockCanvas.Role.SECONDARY_TRUNK, TreeBlockCanvas.Axis.X); + canvas.setDecor(0, 1, 4, 0, "north"); + + TreeCanopyBuilder.build(canvas, tree, 6, offsets, 0, 42L, null); + + assertEquals(TreeBlockCanvas.Role.TRUNK, canvas.get(0, 1, 1).role()); + assertEquals(TreeBlockCanvas.Role.TRUNK, canvas.get(0, 1, 2).role()); + assertEquals(TreeBlockCanvas.Role.SECONDARY_TRUNK, canvas.get(0, 1, 3).role()); + assertEquals(TreeBlockCanvas.Axis.X, canvas.get(0, 1, 3).axis()); + assertEquals(TreeBlockCanvas.Role.DECORATOR, canvas.get(0, 1, 4).role()); + assertFalse(canvas.getLeaf().contains(new TreeBlockCanvas.Vec(0, 1, 1))); + assertFalse(canvas.getLeaf().contains(new TreeBlockCanvas.Vec(0, 1, 2))); + } + + private static IrisProceduralTree branchedTree() { + IrisProceduralTree tree = new IrisProceduralTree(); + IrisTreeBranches branches = new IrisTreeBranches(); + branches.setProbabilityFunction(IrisTreeBranchProbability.CONSTANT); + branches.setProbabilityConstant(1); + branches.setLengthFunction(IrisTreeFunction.CONSTANT); + branches.setLengthConstant(4); + branches.setAzimuthMode(IrisTreeAzimuthMode.CONSTANT); + branches.setAzimuth(0); + branches.setElevation(0); + branches.setClusterRadius(2); + branches.setClusterMode(IrisTreeLeafMode.FILLED); + tree.getCanopy().setBranches(branches); + return tree; + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/decoration/tree/TreeRootBuilderTest.java b/core/src/test/java/art/arcane/iris/generation/decoration/tree/TreeRootBuilderTest.java new file mode 100644 index 000000000..e3f8c9459 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/decoration/tree/TreeRootBuilderTest.java @@ -0,0 +1,218 @@ +package art.arcane.iris.generation.decoration.tree; + +import org.junit.Test; + +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class TreeRootBuilderTest { + @Test + public void everyRootHasAFaceConnectedPathToExistingWood() { + for (IrisTreeRootStyle style : IrisTreeRootStyle.values()) { + for (int width : new int[]{1, 2, 5}) { + for (long seed : new long[]{1L, 42L, 5923L}) { + IrisProceduralTree tree = new IrisProceduralTree(); + tree.setRootStyle(style); + tree.setTrunkWidth(width); + tree.setRootDepth(8); + tree.setRootFlare(7); + tree.setLeanAngle(40); + tree.setLeanAzimuth(45); + TreeBlockCanvas canvas = trunk(tree, 24); + Set original = new HashSet<>(canvas.getTrunk()); + + TreeRootBuilder.build(canvas, tree, 24, seed); + + assertAllRootsConnected(canvas, original); + } + } + } + } + + @Test + public void automaticFlareRemainsCompactForTallThinTrees() { + IrisProceduralTree tree = new IrisProceduralTree(); + for (int height : new int[]{8, 30, 80}) { + TreeBlockCanvas canvas = trunk(tree, height); + + TreeRootBuilder.build(canvas, tree, height, 42L); + + for (TreeBlockCanvas.Vec cell : canvas.getTrunk()) { + if (cell.y() < 0) { + assertTrue(Math.abs(cell.x()) <= 2); + assertTrue(Math.abs(cell.z()) <= 2); + } + } + } + } + + @Test + public void taprootNarrowsToOneCellWithoutExpandingTheBase() { + IrisProceduralTree tree = new IrisProceduralTree(); + tree.setTrunkWidth(5); + tree.setRootDepth(7); + tree.setRootStyle(IrisTreeRootStyle.TAPROOT); + TreeBlockCanvas canvas = trunk(tree, 20); + Set original = new HashSet<>(canvas.getTrunk()); + + TreeRootBuilder.build(canvas, tree, 20, 42L); + + int previousSize = 25; + for (int depth = 1; depth <= 7; depth++) { + int size = 0; + for (TreeBlockCanvas.Vec cell : canvas.getTrunk()) { + if (cell.y() == -depth) { + size++; + assertTrue(original.contains(new TreeBlockCanvas.Vec(cell.x(), 0, cell.z()))); + } + } + assertTrue(size > 0); + assertTrue(size <= previousSize); + previousSize = size; + } + assertEquals(1, previousSize); + } + + @Test + public void buttressTipsEndAtDifferentDepths() { + IrisProceduralTree tree = new IrisProceduralTree(); + tree.setRootDepth(8); + tree.setRootFlare(7); + TreeBlockCanvas canvas = trunk(tree, 20); + + TreeRootBuilder.build(canvas, tree, 20, 42L); + + Map outerDepths = new HashMap<>(); + for (TreeBlockCanvas.Vec cell : canvas.getTrunk()) { + if (cell.y() < 0 && Math.hypot(cell.x(), cell.z()) >= 5) { + TreeBlockCanvas.Vec column = new TreeBlockCanvas.Vec(cell.x(), 0, cell.z()); + outerDepths.merge(column, cell.y(), Math::min); + } + } + assertTrue(new HashSet<>(outerDepths.values()).size() > 1); + } + + @Test + public void explicitFlareKeepsWideStiltRoots() { + IrisProceduralTree tree = new IrisProceduralTree(); + tree.setRootStyle(IrisTreeRootStyle.STILT); + tree.setRootFlare(8); + tree.setRootDepth(5); + TreeBlockCanvas canvas = trunk(tree, 24); + Set original = new HashSet<>(canvas.getTrunk()); + + TreeRootBuilder.build(canvas, tree, 24, 42L); + + double reach = 0; + boolean aboveGround = false; + for (TreeBlockCanvas.Vec cell : canvas.getTrunk()) { + if (!original.contains(cell)) { + reach = Math.max(reach, Math.hypot(cell.x(), cell.z())); + aboveGround |= cell.y() > 0; + } + } + assertTrue(reach >= 6); + assertTrue(aboveGround); + assertAllRootsConnected(canvas, original); + } + + @Test + public void stiltRootsAttachToTheActualLeaningTrunk() { + IrisProceduralTree tree = new IrisProceduralTree(); + tree.setRootStyle(IrisTreeRootStyle.STILT); + tree.setLeanAngle(55); + tree.setLeanAzimuth(90); + tree.setRootFlare(3); + TreeBlockCanvas canvas = trunk(tree, 24); + Set original = new HashSet<>(canvas.getTrunk()); + + TreeRootBuilder.build(canvas, tree, 24, 42L); + + boolean attachedAboveGround = false; + for (TreeBlockCanvas.Vec cell : canvas.getTrunk()) { + if (!original.contains(cell) && cell.y() >= 4) { + for (TreeBlockCanvas.Vec neighbor : neighbors(cell)) { + attachedAboveGround |= original.contains(neighbor); + } + } + } + assertTrue(attachedAboveGround); + assertAllRootsConnected(canvas, original); + } + + @Test + public void intersectingFoliageDoesNotSeverRoots() { + IrisProceduralTree tree = new IrisProceduralTree(); + tree.setRootDepth(5); + tree.setRootFlare(5); + TreeBlockCanvas canvas = trunk(tree, 20); + Set original = new HashSet<>(canvas.getTrunk()); + for (int x = -5; x <= 5; x++) { + for (int z = -5; z <= 5; z++) { + canvas.setLeaf(x, -1, z, TreeBlockCanvas.Role.LEAF); + } + } + + TreeRootBuilder.build(canvas, tree, 20, 42L); + + assertAllRootsConnected(canvas, original); + assertEquals(TreeBlockCanvas.Role.TRUNK, canvas.get(0, -1, 0).role()); + } + + @Test + public void rootGeometryIsDeterministic() { + for (IrisTreeRootStyle style : IrisTreeRootStyle.values()) { + IrisProceduralTree tree = new IrisProceduralTree(); + tree.setRootStyle(style); + tree.setTrunkWidth(2); + TreeBlockCanvas first = trunk(tree, 30); + TreeBlockCanvas second = trunk(tree, 30); + + TreeRootBuilder.build(first, tree, 30, 5923L); + TreeRootBuilder.build(second, tree, 30, 5923L); + + assertEquals(first.getCells(), second.getCells()); + } + } + + private static TreeBlockCanvas trunk(IrisProceduralTree tree, int height) { + TreeBlockCanvas canvas = new TreeBlockCanvas(); + TreeTrunkBuilder.build(canvas, tree, height); + return canvas; + } + + private static void assertAllRootsConnected(TreeBlockCanvas canvas, Set original) { + Set reached = new HashSet<>(original); + ArrayDeque pending = new ArrayDeque<>(original); + while (!pending.isEmpty()) { + TreeBlockCanvas.Vec cell = pending.removeFirst(); + for (TreeBlockCanvas.Vec neighbor : neighbors(cell)) { + if (canvas.getTrunk().contains(neighbor) && reached.add(neighbor)) { + pending.addLast(neighbor); + } + } + } + Set roots = new HashSet<>(canvas.getTrunk()); + roots.removeAll(original); + assertFalse(roots.isEmpty()); + assertEquals(canvas.getTrunk(), reached); + } + + private static TreeBlockCanvas.Vec[] neighbors(TreeBlockCanvas.Vec cell) { + return new TreeBlockCanvas.Vec[]{ + new TreeBlockCanvas.Vec(cell.x() - 1, cell.y(), cell.z()), + new TreeBlockCanvas.Vec(cell.x() + 1, cell.y(), cell.z()), + new TreeBlockCanvas.Vec(cell.x(), cell.y() - 1, cell.z()), + new TreeBlockCanvas.Vec(cell.x(), cell.y() + 1, cell.z()), + new TreeBlockCanvas.Vec(cell.x(), cell.y(), cell.z() - 1), + new TreeBlockCanvas.Vec(cell.x(), cell.y(), cell.z() + 1) + }; + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/CaveObjectPlacementTransactionTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/CaveObjectPlacementTransactionTest.java index b705818a6..a92b31257 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/CaveObjectPlacementTransactionTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/CaveObjectPlacementTransactionTest.java @@ -28,9 +28,11 @@ import art.arcane.iris.generation.block.TileData; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveAction; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.math.BlockPosition; import org.junit.Test; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertSame; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; @@ -42,6 +44,26 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class CaveObjectPlacementTransactionTest { + @Test + public void canopyCommitRechecksSolidsAndSkipsAssociatedMetadata() { + IObjectPlacer delegate = createPlacer(128, 80, 20, 60); + CaveObjectPlacementTransaction transaction = new CaveObjectPlacementTransaction(delegate, 20, 10); + NativeBlockState canopy = mock(NativeBlockState.class); + NativeBlockState trunk = mock(NativeBlockState.class); + when(trunk.isSolid()).thenReturn(true); + when(delegate.beginTreeCanopy(any())).thenCallRealMethod(); + assertTrue(transaction.beginTreeCanopy(new BlockPosition(4, 30, 7))); + transaction.set(4, 30, 7, canopy); + transaction.setData(4, 30, 7, "canopy"); + transaction.endTreeCanopy(); + when(delegate.get(4, 30, 7)).thenReturn(trunk); + + assertEquals(CaveObjectPlacementTransaction.CommitResult.COMMITTED, transaction.commit()); + + verify(delegate, never()).set(4, 30, 7, canopy); + verify(delegate, never()).setData(4, 30, 7, "canopy"); + } + @Test public void validPlacementCommitsBlocksTilesAndMetadataTogether() { IObjectPlacer delegate = createPlacer(128, 80, 20, 60); diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/FloatingObjectPlacementTransactionTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/FloatingObjectPlacementTransactionTest.java index 12a1b15b2..8b35c37db 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/FloatingObjectPlacementTransactionTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/FloatingObjectPlacementTransactionTest.java @@ -21,6 +21,7 @@ package art.arcane.iris.generation.mantle; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.block.TileData; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.math.BlockPosition; import org.junit.Test; import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; @@ -31,6 +32,7 @@ import art.arcane.iris.structure.object.IObjectPlacer; import art.arcane.iris.world.storage.matter.IrisMatterSupport; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertNotNull; import static org.mockito.ArgumentMatchers.any; @@ -42,6 +44,28 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class FloatingObjectPlacementTransactionTest { + @Test + public void canopyCommitRechecksSolidsAndSkipsAssociatedMetadata() { + IslandObjectPlacer delegate = createPlacer(true); + FloatingObjectPlacementTransaction transaction = new FloatingObjectPlacementTransaction(delegate, + new ObjectContinuationBundle.PlacementKey(ObjectContinuationBundle.Kind.FLOATING, 0, 0, 0)); + NativeBlockState canopy = mock(NativeBlockState.class); + NativeBlockState trunk = mock(NativeBlockState.class); + when(trunk.isSolid()).thenReturn(true); + when(delegate.beginTreeCanopy(any())).thenCallRealMethod(); + when(delegate.canWriteObjectBlock(4, 101, 7)).thenReturn(true); + assertTrue(transaction.beginTreeCanopy(new BlockPosition(4, 101, 7))); + transaction.set(4, 101, 7, canopy); + transaction.setData(4, 101, 7, "canopy"); + transaction.endTreeCanopy(); + when(delegate.get(4, 101, 7)).thenReturn(trunk); + + assertEquals(FloatingObjectPlacementTransaction.CommitResult.COMMITTED, transaction.commit()); + + verify(delegate, never()).set(4, 101, 7, canopy); + verify(delegate, never()).setData(4, 101, 7, "canopy"); + } + @Test public void unsupportedFinalTerrainRejectsBlocksTilesAndMarkersTogether() { IslandObjectPlacer delegate = createPlacer(false); diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectContinuationCanopyTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectContinuationCanopyTest.java new file mode 100644 index 000000000..4480c1657 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectContinuationCanopyTest.java @@ -0,0 +1,221 @@ +package art.arcane.iris.generation.mantle; + +import art.arcane.iris.generation.block.TileData; +import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.integration.Identifier; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.structure.object.IObjectPlacer; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.iris.world.storage.matter.IrisMatterSupport; +import art.arcane.iris.world.storage.matter.TileWrapper; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.hunk.Hunk; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.math.BlockPosition; +import art.arcane.volmlib.util.matter.IrisMatter; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterSlice; +import org.junit.After; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ObjectContinuationCanopyTest { + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + private IrisPlatform previous; + private NativeBlockState trunk; + private NativeBlockState canopy; + private NativeBlockState custom; + + @Before + public void bindPalette() { + IrisMatterSupport.ensureRegistered(); + previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatform platform = mock(IrisPlatform.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + trunk = mock(NativeBlockState.class); + canopy = mock(NativeBlockState.class); + custom = mock(NativeBlockState.class); + when(trunk.key()).thenReturn("minecraft:oak_log"); + when(trunk.isSolid()).thenReturn(true); + when(canopy.key()).thenReturn("minecraft:red_stained_glass"); + when(canopy.isSolid()).thenReturn(true); + when(custom.isCustom()).thenReturn(true); + when(custom.placementBaseState()).thenReturn(canopy); + when(custom.deferredPlacementKey()).thenReturn("test:custom_canopy"); + when(platform.registries()).thenReturn(registries); + when(registries.decodeBlockState(trunk.key())).thenReturn(trunk); + when(registries.decodeBlockState(canopy.key())).thenReturn(canopy); + IrisPlatforms.unbind(); + IrisPlatforms.bind(platform); + } + + @After + public void restorePalette() { + IrisPlatforms.unbind(); + if (previous != null) { + IrisPlatforms.bind(previous); + } + } + + @Test + public void encodingKeepsTrunkProvenanceWhenLaterCustomCanopyOverlaps() throws Exception { + Matter payload = decode(List.of( + new ObjectDestinationTransaction.SetMutation(key(16, NativeBlockState.class), trunk, false), + new ObjectDestinationTransaction.SetMutation(key(16, String.class), "original-trunk", false), + new ObjectDestinationTransaction.CustomBlockMutation(key(16, NativeBlockState.class), custom, true), + new ObjectDestinationTransaction.SetMutation(key(16, String.class), "rejected-canopy", true), + new ObjectDestinationTransaction.CustomBlockMutation(key(17, NativeBlockState.class), custom, true))); + + assertSame(trunk, payload.getSlice(NativeBlockState.class).get(0, 5, 1)); + assertEquals("original-trunk", payload.getSlice(String.class).get(0, 5, 1)); + assertNull(payload.getSlice(Identifier.class).get(0, 5, 1)); + assertNull(payload.getSlice(TreeCanopy.class).get(0, 5, 1)); + assertSame(canopy, payload.getSlice(NativeBlockState.class).get(1, 5, 1)); + assertEquals(Identifier.fromString("test:custom_canopy"), payload.getSlice(Identifier.class).get(1, 5, 1)); + assertSame(TreeCanopy.INSTANCE, payload.getSlice(TreeCanopy.class).get(1, 5, 1)); + } + + @Test + public void unconditionalBlockClearsPreviousCanopyCondition() throws Exception { + Matter payload = decode(List.of( + new ObjectDestinationTransaction.CustomBlockMutation(key(16, NativeBlockState.class), custom, true), + new ObjectDestinationTransaction.SetMutation(key(16, NativeBlockState.class), trunk, false))); + + assertSame(trunk, payload.getSlice(NativeBlockState.class).get(0, 5, 1)); + MatterSlice conditions = payload.getSlice(TreeCanopy.class); + assertTrue(conditions == null || conditions.get(0, 5, 1) == null); + MatterSlice identifiers = payload.getSlice(Identifier.class); + assertTrue(identifiers == null || identifiers.get(0, 5, 1) == null); + } + + @Test + public void replayDefersCanopyAndPreservesLaterStructureMetadata() throws Exception { + ReplayFixture fixture = new ReplayFixture(); + Matter payload = customPayload(); + ObjectContinuationPersistence.restore(fixture.mantle, 1, 0, payload, fixture.blocks, false); + assertFalse(fixture.section.hasSlice(Identifier.class)); + assertNull(fixture.blocks.get(0, 5, 1)); + fixture.blocks.set(0, 5, 1, trunk); + Identifier existingId = Identifier.fromString("test:existing_structure"); + TreeBlockMaterial provenance = TreeBlockMaterial.of("minecraft:oak_log"); + TileWrapper tile = new TileWrapper(mock(TileData.class)); + fixture.section.slice(NativeBlockState.class).set(0, 5, 1, trunk); + fixture.section.slice(Identifier.class).set(0, 5, 1, existingId); + fixture.section.slice(String.class).set(0, 5, 1, "original-trunk"); + fixture.section.slice(TreeBlockMaterial.class).set(0, 5, 1, provenance); + fixture.section.slice(TileWrapper.class).set(0, 5, 1, tile); + + ObjectContinuationPersistence.restore(fixture.mantle, 1, 0, payload, fixture.blocks, true); + + assertSame(trunk, fixture.blocks.get(0, 5, 1)); + assertSame(trunk, fixture.section.getSlice(NativeBlockState.class).get(0, 5, 1)); + assertEquals(existingId, fixture.section.getSlice(Identifier.class).get(0, 5, 1)); + assertEquals("original-trunk", fixture.section.getSlice(String.class).get(0, 5, 1)); + assertEquals(provenance, fixture.section.getSlice(TreeBlockMaterial.class).get(0, 5, 1)); + assertSame(tile, fixture.section.getSlice(TileWrapper.class).get(0, 5, 1)); + assertFalse(fixture.section.hasSlice(TreeCanopy.class)); + } + + @Test + public void replayPlacesCustomCanopyIntoAirAndClearsOldTileMetadata() throws Exception { + ReplayFixture fixture = new ReplayFixture(); + fixture.section.slice(TileWrapper.class).set(0, 5, 1, new TileWrapper(mock(TileData.class))); + fixture.section.slice(TreeBlockMaterial.class).set(0, 5, 1, TreeBlockMaterial.of("minecraft:oak_log")); + + ObjectContinuationPersistence.restore(fixture.mantle, 1, 0, customPayload(), fixture.blocks, true); + + assertSame(canopy, fixture.blocks.get(0, 5, 1)); + assertEquals(Identifier.fromString("test:custom_canopy"), fixture.section.getSlice(Identifier.class).get(0, 5, 1)); + assertEquals("incoming-canopy", fixture.section.getSlice(String.class).get(0, 5, 1)); + assertNull(fixture.section.getSlice(TileWrapper.class).get(0, 5, 1)); + assertNull(fixture.section.getSlice(TreeBlockMaterial.class).get(0, 5, 1)); + assertFalse(fixture.section.hasSlice(TreeCanopy.class)); + } + + @Test + @SuppressWarnings("unchecked") + public void floatingContinuationRetainsCustomCanopyCondition() throws Exception { + IObjectPlacer delegate = mock(IObjectPlacer.class); + Engine engine = mock(Engine.class); + EngineMantle engineMantle = mock(EngineMantle.class); + Mantle mantle = mock(Mantle.class); + MantleChunk firstChunk = mock(MantleChunk.class); + MantleChunk secondChunk = mock(MantleChunk.class); + Matter first = new IrisMatter(16, 16, 16); + Matter second = new IrisMatter(16, 16, 16); + when(delegate.getEngine()).thenReturn(engine); + when(delegate.beginTreeCanopy(any())).thenCallRealMethod(); + when(engine.getHeight()).thenReturn(64); + when(engine.getMantle()).thenReturn(engineMantle); + when(engineMantle.getMantle()).thenReturn(mantle); + when(mantle.useChunk(0, 0)).thenReturn(firstChunk); + when(mantle.useChunk(1, 0)).thenReturn(secondChunk); + when(firstChunk.getOrCreate(0)).thenReturn(first); + when(secondChunk.getOrCreate(0)).thenReturn(second); + FloatingObjectPlacementTransaction transaction = new FloatingObjectPlacementTransaction(delegate, + new ObjectContinuationBundle.PlacementKey(ObjectContinuationBundle.Kind.FLOATING, 0, 0, 0)); + transaction.set(15, 5, 1, trunk); + assertTrue(transaction.beginTreeCanopy(new BlockPosition(16, 5, 1))); + transaction.set(16, 5, 1, custom); + transaction.setData(16, 5, 1, "floating-canopy"); + transaction.endTreeCanopy(); + + assertEquals(FloatingObjectPlacementTransaction.CommitResult.COMMITTED, transaction.commit()); + + Matter payload = second.getSlice(ObjectContinuationBundle.class) + .get(0, 0, 0).fragments().getFirst().decode(); + assertSame(TreeCanopy.INSTANCE, payload.getSlice(TreeCanopy.class).get(0, 5, 1)); + assertSame(canopy, payload.getSlice(NativeBlockState.class).get(0, 5, 1)); + assertEquals(Identifier.fromString("test:custom_canopy"), payload.getSlice(Identifier.class).get(0, 5, 1)); + assertEquals("floating-canopy", payload.getSlice(String.class).get(0, 5, 1)); + } + + private Matter customPayload() throws IOException { + return decode(List.of( + new ObjectDestinationTransaction.CustomBlockMutation(key(16, NativeBlockState.class), custom, true), + new ObjectDestinationTransaction.SetMutation(key(16, String.class), "incoming-canopy", true))); + } + + private static Matter decode(List mutations) throws IOException { + return Matter.read(new ByteArrayInputStream(ObjectSourcePlan.encode(mutations, 64))); + } + + private static ObjectDestinationTransaction.DataKey key(int x, Class type) { + return new ObjectDestinationTransaction.DataKey(x, 5, 1, type); + } + + private static final class ReplayFixture { + private final Mantle mantle; + private final Matter section = new IrisMatter(16, 16, 16); + private final Hunk blocks = Hunk.newArrayHunk(16, 64, 16); + + @SuppressWarnings("unchecked") + private ReplayFixture() { + mantle = mock(Mantle.class); + MantleChunk chunk = mock(MantleChunk.class); + when(mantle.useChunk(1, 0)).thenReturn(chunk); + when(chunk.get(0)).thenReturn(section); + when(chunk.getOrCreate(0)).thenReturn(section); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectContinuationTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectContinuationTest.java index 787828f11..f630498de 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectContinuationTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectContinuationTest.java @@ -166,11 +166,11 @@ public class ObjectContinuationTest { properties.put("CustomName", "continued chest"); TileWrapper tile = new TileWrapper(new TileData("minecraft:chest", properties)); List mutations = List.of( - new ObjectDestinationTransaction.CustomBlockMutation(key(NativeBlockState.class), custom), - new ObjectDestinationTransaction.SetMutation(key(String.class), "tree@42"), - new ObjectDestinationTransaction.SetMutation(key(TreeBlockMaterial.class), TreeBlockMaterial.of("minecraft:oak_log")), - new ObjectDestinationTransaction.SetMutation(key(TileWrapper.class), tile), - new ObjectDestinationTransaction.SetMutation(new ObjectDestinationTransaction.DataKey(17, 5, 1, NativeBlockState.class), air)); + new ObjectDestinationTransaction.CustomBlockMutation(key(NativeBlockState.class), custom, false), + new ObjectDestinationTransaction.SetMutation(key(String.class), "tree@42", false), + new ObjectDestinationTransaction.SetMutation(key(TreeBlockMaterial.class), TreeBlockMaterial.of("minecraft:oak_log"), false), + new ObjectDestinationTransaction.SetMutation(key(TileWrapper.class), tile, false), + new ObjectDestinationTransaction.SetMutation(new ObjectDestinationTransaction.DataKey(17, 5, 1, NativeBlockState.class), air, false)); ObjectContinuationBundle.Fragment fragment = new ObjectContinuationBundle.Fragment( new ObjectContinuationBundle.PlacementKey(ObjectContinuationBundle.Kind.BIOME, 0, 0, 2), new ObjectContinuationBundle.Bounds(15, 1, 17, 1), List.of(new ObjectContinuationBundle.ChunkPosition(0, 0), new ObjectContinuationBundle.ChunkPosition(1, 0)), @@ -215,7 +215,7 @@ public class ObjectContinuationTest { ObjectContinuationBundle.Fragment fragment = new ObjectContinuationBundle.Fragment( new ObjectContinuationBundle.PlacementKey(ObjectContinuationBundle.Kind.BIOME, 0, 0, 0), new ObjectContinuationBundle.Bounds(15, 1, 16, 1), List.of(new ObjectContinuationBundle.ChunkPosition(0, 0), new ObjectContinuationBundle.ChunkPosition(1, 0)), - ObjectSourcePlan.encode(List.of(new ObjectDestinationTransaction.SetMutation(key(IrisSpawner.class), spawner)), 64)); + ObjectSourcePlan.encode(List.of(new ObjectDestinationTransaction.SetMutation(key(IrisSpawner.class), spawner, false)), 64)); try (IrisMatterContext.Scope outer = IrisMatterContext.open(current)) { assertThrows(IOException.class, fragment::decode); @@ -278,7 +278,7 @@ public class ObjectContinuationTest { } private static ObjectDestinationTransaction.Mutation marker(int x, String marker) { - return new ObjectDestinationTransaction.SetMutation(new ObjectDestinationTransaction.DataKey(x, 5, 1, String.class), marker); + return new ObjectDestinationTransaction.SetMutation(new ObjectDestinationTransaction.DataKey(x, 5, 1, String.class), marker, false); } private static final class Fixture { diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java index f4165d6c5..cdce645c0 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java @@ -14,6 +14,8 @@ import art.arcane.iris.generation.terrain.Terrain3DColumnFixtures; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.math.BlockPosition; +import art.arcane.iris.structure.object.DecayControlPlacer; import art.arcane.iris.spi.PlatformRegistries; import art.arcane.iris.testsupport.BukkitTestServer; import art.arcane.iris.world.storage.matter.IrisMatterSupport; @@ -51,6 +53,89 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class ObjectDestinationTransactionTest { + @Test + public void separateSourceCanopiesPreservePriorSolidsAndTheirMetadata() { + for (boolean custom : new boolean[]{false, true}) { + MantleWriter writer = writer(); + NativeBlockState trunk = mock(NativeBlockState.class); + NativeBlockState canopy = mock(NativeBlockState.class); + NativeBlockState base = mock(NativeBlockState.class); + when(trunk.isSolid()).thenReturn(true); + if (custom) { + when(canopy.isCustom()).thenReturn(true); + when(canopy.deferredPlacementKey()).thenReturn("test:canopy"); + when(canopy.placementBaseState()).thenReturn(base); + } + TreeBlockMaterial trunkMaterial = new TreeBlockMaterial("test:trunk"); + TreeBlockMaterial canopyMaterial = new TreeBlockMaterial("test:canopy"); + ObjectDestinationTransaction source = new ObjectDestinationTransaction(writer, 0, 0); + DecayControlPlacer placer = new DecayControlPlacer(source); + for (int x : new int[]{17, 18}) { + assertTrue(placer.beginTreeCanopy(new BlockPosition(x, 7, 1))); + placer.set(x, 7, 1, canopy); + placer.setData(x, 7, 1, "canopy"); + placer.setData(x, 7, 1, canopyMaterial); + placer.endTreeCanopy(); + } + ObjectSourcePlan plan = source.sourcePlanSince(0); + for (ObjectDestinationTransaction.Mutation mutation : plan.mutationsFor(1, 0)) { + assertTrue(mutation.canopy()); + } + ObjectDestinationTransaction destination = new ObjectDestinationTransaction(writer, 1, 0); + destination.set(17, 7, 1, trunk); + destination.setData(17, 7, 1, "trunk"); + destination.setData(17, 7, 1, trunkMaterial); + destination.apply(plan); + assertSame(trunk, destination.get(17, 7, 1)); + assertEquals("trunk", destination.getDataIfPresent(17, 7, 1, String.class)); + assertEquals(trunkMaterial, destination.getDataIfPresent(17, 7, 1, TreeBlockMaterial.class)); + assertNull(destination.getDataIfPresent(17, 7, 1, Identifier.class)); + assertSame(custom ? base : canopy, destination.get(18, 7, 1)); + assertEquals("canopy", destination.getDataIfPresent(18, 7, 1, String.class)); + destination.commit(); + verify(writer, never()).set(17, 7, 1, canopy); + verify(writer, never()).setData(17, 7, 1, canopy); + verify(writer, never()).setData(17, 7, 1, "canopy"); + verify(writer, never()).setData(17, 7, 1, canopyMaterial); + } + } + + @Test + public void skippedCanopyDoesNotSuppressLaterWoodAtTheSameCell() { + MantleWriter writer = writer(); + NativeBlockState existing = mock(NativeBlockState.class); + NativeBlockState canopy = mock(NativeBlockState.class); + NativeBlockState replacement = mock(NativeBlockState.class); + when(existing.isSolid()).thenReturn(true); + ObjectDestinationTransaction source = new ObjectDestinationTransaction(writer, 0, 0); + assertTrue(source.beginTreeCanopy(new BlockPosition(17, 7, 1))); + source.set(17, 7, 1, canopy); + source.setData(17, 7, 1, "canopy"); + source.endTreeCanopy(); + source.set(17, 7, 1, replacement); + source.setData(17, 7, 1, "replacement"); + ObjectDestinationTransaction destination = new ObjectDestinationTransaction(writer, 1, 0); + destination.set(17, 7, 1, existing); + destination.apply(source.sourcePlanSince(0)); + assertSame(replacement, destination.get(17, 7, 1)); + assertEquals("replacement", destination.getDataIfPresent(17, 7, 1, String.class)); + destination.commit(); + verify(writer, never()).setData(17, 7, 1, canopy); + verify(writer, never()).setData(17, 7, 1, "canopy"); + verify(writer).setData(17, 7, 1, replacement); + } + + @Test + public void canopyCaptureRejectsExistingSolidsWithoutMutations() { + MantleWriter writer = writer(); + NativeBlockState solid = mock(NativeBlockState.class); + when(solid.isSolid()).thenReturn(true); + when(writer.getPrerequisiteBlock(4, 7, 1)).thenReturn(solid); + ObjectDestinationTransaction source = new ObjectDestinationTransaction(writer, 0, 0); + assertFalse(source.beginTreeCanopy(new BlockPosition(4, 7, 1))); + assertEquals(0, source.mutationCheckpoint()); + } + @Test public void sourceCaptureCannotReplaceAuthoredRoomSolids() { MantleWriter writer = writer(); diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectSourcePlanCacheTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectSourcePlanCacheTest.java index a0caf34eb..25d05d0b2 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectSourcePlanCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectSourcePlanCacheTest.java @@ -326,7 +326,7 @@ public class ObjectSourcePlanCacheTest { List mutations = new ArrayList<>(); for (int[] chunk : chunks) { mutations.add(new ObjectDestinationTransaction.SetMutation( - new ObjectDestinationTransaction.DataKey(chunk[0] << 4, 4, chunk[1] << 4, String.class), "marker")); + new ObjectDestinationTransaction.DataKey(chunk[0] << 4, 4, chunk[1] << 4, String.class), "marker", false)); } return new ObjectSourcePlan(mutations); } diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectSourcePlanTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectSourcePlanTest.java index c748ef7df..029dc7e33 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectSourcePlanTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectSourcePlanTest.java @@ -74,14 +74,14 @@ public class ObjectSourcePlanTest { int z = centerZ + random.nextInt(2 * reach + 1) - reach; int y = random.nextInt(768); mutations.add(switch (random.nextInt(6)) { - case 0 -> new ObjectDestinationTransaction.CustomBlockMutation(key(x, y, z, NativeBlockState.class), custom); - case 1 -> new ObjectDestinationTransaction.SetMutation(key(x, y, z, String.class), placementMarker); + case 0 -> new ObjectDestinationTransaction.CustomBlockMutation(key(x, y, z, NativeBlockState.class), custom, random.nextBoolean()); + case 1 -> new ObjectDestinationTransaction.SetMutation(key(x, y, z, String.class), placementMarker, random.nextBoolean()); case 2 -> new ObjectDestinationTransaction.SetMutation(key(x, y, z, TreeBlockMaterial.class), - new TreeBlockMaterial("minecraft:oak_log")); - case 3 -> new ObjectDestinationTransaction.SetMutation(key(x, y, z, MatterMarker.class), marker); + new TreeBlockMaterial("minecraft:oak_log"), random.nextBoolean()); + case 3 -> new ObjectDestinationTransaction.SetMutation(key(x, y, z, MatterMarker.class), marker, random.nextBoolean()); default -> { NativeBlockState state = states[random.nextInt(states.length)]; - yield new ObjectDestinationTransaction.SetMutation(key(x, y, z, NativeBlockState.class), state); + yield new ObjectDestinationTransaction.SetMutation(key(x, y, z, NativeBlockState.class), state, random.nextBoolean()); } }); } @@ -109,11 +109,11 @@ public class ObjectSourcePlanTest { @Test public void heightsOutsideThePackedRangeAreRejected() { assertThrows(IllegalArgumentException.class, () -> new ObjectSourcePlan(List.of( - new ObjectDestinationTransaction.SetMutation(key(0, -1, 0, String.class), "marker")))); + new ObjectDestinationTransaction.SetMutation(key(0, -1, 0, String.class), "marker", false)))); assertThrows(IllegalArgumentException.class, () -> new ObjectSourcePlan(List.of( - new ObjectDestinationTransaction.SetMutation(key(0, 1 << 22, 0, String.class), "marker")))); + new ObjectDestinationTransaction.SetMutation(key(0, 1 << 22, 0, String.class), "marker", false)))); assertThrows(IllegalArgumentException.class, () -> new ObjectSourcePlan(List.of( - new ObjectDestinationTransaction.SetMutation(key(0, 4, 0, Object.class), "marker")))); + new ObjectDestinationTransaction.SetMutation(key(0, 4, 0, Object.class), "marker", false)))); } @Test @@ -139,7 +139,7 @@ public class ObjectSourcePlanTest { List trees = new ArrayList<>(); for (int index = 0; index < 100; index++) { trees.add(new ObjectDestinationTransaction.SetMutation(key(index & 15, 4, 0, TreeBlockMaterial.class), - new TreeBlockMaterial("minecraft:oak_log"))); + new TreeBlockMaterial("minecraft:oak_log"), false)); } int single = new ObjectSourcePlan(one).estimatedRetainedBytes(); @@ -150,15 +150,15 @@ public class ObjectSourcePlanTest { @Test public void retainedBytesIncludeVariableLengthMarkers() { ObjectDestinationTransaction.DataKey key = new ObjectDestinationTransaction.DataKey(0, 4, 0, String.class); - ObjectSourcePlan shortMarker = new ObjectSourcePlan(List.of(new ObjectDestinationTransaction.SetMutation(key, "a"))); - ObjectSourcePlan longMarker = new ObjectSourcePlan(List.of(new ObjectDestinationTransaction.SetMutation(key, "a".repeat(4096)))); + ObjectSourcePlan shortMarker = new ObjectSourcePlan(List.of(new ObjectDestinationTransaction.SetMutation(key, "a", false))); + ObjectSourcePlan longMarker = new ObjectSourcePlan(List.of(new ObjectDestinationTransaction.SetMutation(key, "a".repeat(4096), false))); assertTrue(longMarker.estimatedRetainedBytes() - shortMarker.estimatedRetainedBytes() >= 8190); } @Test public void unknownPayloadsAreWeighedInsteadOfDisablingRetention() { ObjectSourcePlan unknown = new ObjectSourcePlan(List.of(new ObjectDestinationTransaction.SetMutation( - key(0, 4, 0, Object.class), new Object()))); + key(0, 4, 0, Object.class), new Object(), false))); assertTrue(unknown.estimatedRetainedBytes() > new ObjectSourcePlan(List.of(mutationAt(0, 0))).estimatedRetainedBytes()); assertTrue(unknown.estimatedRetainedBytes() < 4096); @@ -169,9 +169,9 @@ public class ObjectSourcePlanTest { List shortMarkers = List.of(mutationAt(0, 0), mutationAt(16, 0)); List longMarkers = List.of( new ObjectDestinationTransaction.SetMutation( - new ObjectDestinationTransaction.DataKey(0, 4, 0, String.class), "a".repeat(4096)), + new ObjectDestinationTransaction.DataKey(0, 4, 0, String.class), "a".repeat(4096), false), new ObjectDestinationTransaction.SetMutation( - new ObjectDestinationTransaction.DataKey(16, 4, 0, String.class), "b".repeat(4096))); + new ObjectDestinationTransaction.DataKey(16, 4, 0, String.class), "b".repeat(4096), false)); ObjectDestinationTransaction.PlacementRange whole = new ObjectDestinationTransaction.PlacementRange(0, 2); ObjectSourcePlan shortPlain = new ObjectSourcePlan(shortMarkers); ObjectSourcePlan shortCrossing = new ObjectSourcePlan(shortMarkers, List.of(whole)); @@ -224,6 +224,6 @@ public class ObjectSourcePlanTest { private static ObjectDestinationTransaction.Mutation mutationAt(int x, int z) { return new ObjectDestinationTransaction.SetMutation( - new ObjectDestinationTransaction.DataKey(x, 4, z, String.class), "marker"); + new ObjectDestinationTransaction.DataKey(x, 4, z, String.class), "marker", false); } } diff --git a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java index 05de3a92c..0f74c50a1 100644 --- a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java +++ b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java @@ -83,7 +83,7 @@ public class SchemaBuilderIdentityTest { @ClassRule public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); - private static final String EXPECTED_SCHEMA_DIGEST = "c99b0db94bfd6f316738beb87401edde5b52eed11bfa419148bb2e8c08b0d7a9"; + private static final String EXPECTED_SCHEMA_DIGEST = "f0be31c11f7b03061a88387c66578058f447e5803cabe536a34721066d2bc636"; private static final List> SCHEMA_ROOTS = List.of( IrisDimension.class, diff --git a/core/src/test/java/art/arcane/iris/structure/object/TreeGroundingPlacementTest.java b/core/src/test/java/art/arcane/iris/structure/object/TreeGroundingPlacementTest.java new file mode 100644 index 000000000..23b6050a2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/structure/object/TreeGroundingPlacementTest.java @@ -0,0 +1,498 @@ +package art.arcane.iris.structure.object; + +import art.arcane.iris.generation.decoration.tree.IrisProceduralTree; +import art.arcane.iris.generation.decoration.tree.ProceduralTreeGenerator; +import art.arcane.iris.generation.decoration.tree.TreeBlockCanvas; +import art.arcane.iris.generation.decoration.tree.TreeGrounding; +import art.arcane.iris.generation.geometry.IrisBlockVector; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.PlatformBinding; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class TreeGroundingPlacementTest { + private static final int SURFACE_Y = 80; + + @Rule + public final PlatformBinding platform = PlatformBinding.mockPlatform(); + + private IrisData data; + private NativeBlockState wood; + private NativeBlockState leaves; + + @Before + public void bindStates() { + wood = state("minecraft:oak_log"); + leaves = state("minecraft:oak_leaves"); + when(leaves.isFoliage()).thenReturn(true); + Engine engine = mock(Engine.class); + when(engine.getHeight()).thenReturn(256); + data = mock(IrisData.class); + when(data.getEngine()).thenReturn(engine); + } + + @Test + public void flatGroundKeepsTheAuthoredBase() { + IrisObject tree = tree(true); + Terrain terrain = new Terrain(); + + assertTrue(place(tree, terrain, placement()) >= 0); + + assertEquals(tree.getBlocks().size(), terrain.written.size()); + assertSame(wood, terrain.written.get(new Position(0, 80, 0))); + assertSame(wood, terrain.written.get(new Position(1, 81, 0))); + assertFalse(terrain.written.containsKey(new Position(1, 79, 0))); + } + + @Test + public void gentleSlopeExtendsOnlyUnsupportedRootColumns() { + Terrain terrain = new Terrain(); + terrain.heights.put(new Column(1, 0), 77); + + assertTrue(place(tree(true), terrain, placement()) >= 0); + + for (int y = 77; y <= 81; y++) { + assertSame(wood, terrain.written.get(new Position(1, y, 0))); + } + assertFalse(terrain.written.containsKey(new Position(0, 79, 0))); + assertFalse(terrain.written.containsKey(new Position(2, 79, 0))); + } + + @Test + public void cliffRejectionWritesNoPartOfTheTreeOrListener() { + Terrain terrain = new Terrain(); + terrain.heights.put(new Column(1, 0), 70); + Map observed = new HashMap<>(); + + int result = tree(true).place(0, -1, 0, terrain.placer, placement(), new RNG(42L), + (position, state) -> observed.put(new Position(position.getX(), position.getY(), position.getZ()), state), + null, data); + + assertEquals(-1, result); + assertTrue(terrain.written.isEmpty()); + assertTrue(observed.isEmpty()); + } + + @Test + public void carvedRootContactRejectsBeforeWriting() { + Terrain terrain = new Terrain(); + terrain.carved.add(new Position(1, 79, 0)); + + assertEquals(-1, place(tree(true), terrain, placement())); + + assertTrue(terrain.written.isEmpty()); + } + + @Test + public void rotatedTranslatedRootsUseTheirFinalWorldCoordinates() { + Terrain terrain = new Terrain(); + terrain.heights.put(new Column(13, 17), 78); + IrisObjectPlacement placement = placement(); + placement.setRotation(IrisObjectRotation.of(0, 90, 0)); + placement.setTranslate(new IrisObjectTranslate().setX(2).setY(1).setZ(3)); + + int result = tree(true).place(10, -1, 20, terrain.placer, placement, new RNG(42L), data); + + assertTrue(result >= 0); + assertSame(wood, terrain.written.get(new Position(13, 81, 18))); + for (int y = 78; y <= 82; y++) { + assertSame(wood, terrain.written.get(new Position(13, y, 17))); + } + assertFalse(terrain.written.containsKey(new Position(11, 80, 20))); + } + + @Test + public void rootlessTreesRequireContactWithoutAddingRoots() { + Terrain flat = new Terrain(); + IrisObject rootless = tree(false); + assertTrue(place(rootless, flat, placement()) >= 0); + assertEquals(rootless.getBlocks().size(), flat.written.size()); + + Terrain slope = new Terrain(); + slope.heights.put(new Column(1, 0), 79); + + assertEquals(-1, place(rootless, slope, placement())); + assertTrue(slope.written.isEmpty()); + } + + @Test + public void placementListenerReceivesEveryGroundingBlock() { + Terrain terrain = new Terrain(); + terrain.heights.put(new Column(1, 0), 77); + Map observed = new HashMap<>(); + + int result = tree(true).place(0, -1, 0, terrain.placer, placement(), new RNG(42L), + (position, state) -> observed.put(new Position(position.getX(), position.getY(), position.getZ()), state), + null, data); + + assertTrue(result >= 0); + assertEquals(terrain.written, observed); + assertSame(wood, observed.get(new Position(1, 77, 0))); + } + + @Test + public void repeatedPlacementDoesNotMutateCachedVariants() { + IrisObject tree = tree(true); + Map original = snapshot(tree); + TreeGrounding originalGrounding = tree.getTreeGrounding(); + Terrain first = new Terrain(); + first.heights.put(new Column(1, 0), 77); + Terrain cliff = new Terrain(); + cliff.heights.put(new Column(1, 0), 60); + Terrain second = new Terrain(); + second.heights.put(new Column(1, 0), 77); + + assertTrue(place(tree, first, placement()) >= 0); + assertEquals(-1, place(tree, cliff, placement())); + assertTrue(place(tree, second, placement()) >= 0); + + assertEquals(first.written, second.written); + assertEquals(original, snapshot(tree)); + assertSame(originalGrounding, tree.getTreeGrounding()); + } + + @Test + public void canopyOverhangDoesNotBecomeAGroundSupportFootprint() { + Terrain terrain = new Terrain(); + terrain.heights.put(new Column(4, 0), 20); + terrain.carved.add(new Position(4, 20, 0)); + + assertTrue(place(tree(true), terrain, placement()) >= 0); + + assertSame(leaves, terrain.written.get(new Position(4, 84, 0))); + assertFalse(terrain.written.containsKey(new Position(4, 80, 0))); + } + + @Test + public void genericStiltingDoesNotRepeatTheRootBase() { + IrisObject tree = tree(true); + Terrain ordinary = new Terrain(); + ordinary.heights.put(new Column(1, 0), 77); + Terrain stilted = new Terrain(); + stilted.heights.put(new Column(1, 0), 77); + + assertTrue(place(tree, ordinary, placement()) >= 0); + assertTrue(place(tree, stilted, placement().setMode(ObjectPlaceMode.CENTER_STILT)) >= 0); + + assertEquals(ordinary.written, stilted.written); + } + + @Test + public void generatedLeaningTreesKeepTheirTrunkAtThePlacementOrigin() { + NativeBlockState stone = state("minecraft:stone"); + when(platform.registries().block(anyString())).thenReturn(stone); + when(platform.registries().blockOrNull(anyString(), anyBoolean())).thenReturn(stone); + IrisProceduralTree definition = new IrisProceduralTree(); + definition.setTrunk("minecraft:stone"); + definition.setLeaves("minecraft:stone"); + definition.setLeanAngle(40); + definition.setLeanAzimuth(90); + definition.setRoots(false); + definition.setPlausible(false); + + IrisObject generated = ProceduralTreeGenerator.generate(definition, 20, new RNG(42L), data); + + assertNotNull(generated); + assertNotNull(generated.getTreeGrounding()); + List roots = generated.getTreeGrounding().roots(); + assertEquals(1, roots.size()); + assertEquals(0, roots.getFirst().x()); + assertEquals(0, roots.getFirst().z()); + assertEquals(1 - generated.getCenter().getBlockY(), roots.getFirst().y()); + Terrain terrain = new Terrain(); + assertTrue(place(generated, terrain, placement()) >= 0); + assertSame(stone, terrain.written.get(new Position(0, 81, 0))); + } + + @Test + public void scaledVariantsRetainGroundingAtCliffs() { + IrisObject source = tree(true); + Map original = snapshot(source); + for (double scale : new double[]{1.5, 2, 3}) { + IrisObjectPlacement placement = placement(); + IrisObject scaled = placement.scaleObject(new RNG(42L), source, + new IrisDimension().setAllObjectScaleFactor(scale)); + assertNotSame(source, scaled); + assertGroundingMatchesBlocks(scaled); + Terrain terrain = new Terrain(); + for (TreeGrounding.Root root : scaled.getTreeGrounding().roots()) { + if (root.x() != 0 || root.z() != 0) { + terrain.heights.put(new Column(root.x(), root.z()), 40); + } + } + assertFalse(terrain.heights.isEmpty()); + + assertEquals(-1, place(scaled, terrain, placement)); + + assertTrue(terrain.written.isEmpty()); + assertEquals(original, snapshot(source)); + } + } + + @Test + public void scaledRootMetadataMatchesBlocksForBothOrigins() { + IrisObject source = tree(true); + TreeGrounding grounding = source.getTreeGrounding(); + for (double scale : new double[]{0.5, 1.5, 2, 3}) { + IrisObject centered = source.scaled(scale, IrisObjectPlacementScaleInterpolator.NONE); + IrisObject savedOrigin = source.scaledAroundOrigin(scale, IrisObjectPlacementScaleInterpolator.NONE); + + assertGroundingMatchesBlocks(centered); + assertGroundingMatchesBlocks(savedOrigin); + assertEquals((int) Math.ceil(3 * scale), centered.getTreeGrounding().maxExtension()); + assertEquals((int) Math.ceil(3 * scale), savedOrigin.getTreeGrounding().maxExtension()); + assertSame(grounding, source.getTreeGrounding()); + } + } + + @Test + public void rotatedCopiesKeepMetadataOnTheirActualRootBlocks() { + IrisObject source = tree(true); + Map original = snapshot(source); + TreeGrounding grounding = source.getTreeGrounding(); + for (int angle : new int[]{45, 90, 180, 270}) { + IrisObject rotated = source.rotateCopy(IrisObjectRotation.of(0, angle, 0)); + + assertGroundingMatchesBlocks(rotated); + assertEquals(3, rotated.getTreeGrounding().maxExtension()); + assertEquals(original, snapshot(source)); + assertSame(grounding, source.getTreeGrounding()); + } + } + + @Test + public void shrinkwrapMovesRootMetadataAndInvalidatesItsFootprint() { + IrisObject source = tree(true); + Map original = snapshot(source); + IrisObject shrunk = source.copy(); + List oldFootprint = List.copyOf(shrunk.getSurfaceSupportOffsets()); + + shrunk.shrinkwrap(); + + assertGroundingMatchesBlocks(shrunk); + assertFalse(oldFootprint.equals(shrunk.getSurfaceSupportOffsets())); + assertEquals(shrunk.getTreeGrounding().supportOffsets(), shrunk.getSurfaceSupportOffsets()); + assertEquals(original, snapshot(source)); + } + + @Test + public void overlappingCanopiesPreserveWoodAndSkipListenerWrites() { + for (Palette palette : List.of(new Palette("minecraft:oak_wood", "minecraft:birch_leaves"), + new Palette("minecraft:warped_hyphae", "minecraft:warped_wart_block"), + new Palette("test:trunk", "test:canopy"))) { + wood = state(palette.trunk()); + leaves = state(palette.canopy()); + Terrain terrain = new Terrain(); + IrisObject earlier = tree(true); + earlier.getBlocks().put(new IrisBlockVector(0, 1, 0), wood); + assertTrue(earlier.place(4, -1, 0, terrain.placer, placement(), new RNG(42L), data) >= 0); + IrisObject later = tree(true); + later.getBlocks().put(new IrisBlockVector(4, 2, 0), leaves); + later.setTreeGrounding(TreeGrounding.create(later.getTreeGrounding().roots(), 3, + Set.of(new TreeBlockCanvas.Vec(4, 1, 0), new TreeBlockCanvas.Vec(4, 2, 0)))); + Map observed = new HashMap<>(); + + int result = later.place(0, -1, 0, terrain.placer, placement(), new RNG(42L), + (position, state) -> observed.put(new Position(position.getX(), position.getY(), position.getZ()), state), + null, data); + + assertTrue(result >= 0); + assertSame(wood, terrain.written.get(new Position(4, 84, 0))); + assertFalse(observed.containsKey(new Position(4, 84, 0))); + assertSame(leaves, observed.get(new Position(4, 85, 0))); + } + } + + @Test + public void canopyRolesRemainDistinctWhenWoodAndLeavesShareAMaterial() { + leaves = wood; + NativeBlockState existing = state("test:older_trunk"); + Terrain terrain = new Terrain(); + terrain.written.put(new Position(4, 84, 0), existing); + terrain.written.put(new Position(0, 83, 0), existing); + + assertTrue(place(tree(true), terrain, placement()) >= 0); + + assertSame(existing, terrain.written.get(new Position(4, 84, 0))); + assertSame(wood, terrain.written.get(new Position(0, 83, 0))); + } + + @Test + public void treeWoodStillReplacesOverlappingCanopy() { + Terrain terrain = new Terrain(); + terrain.written.put(new Position(0, 83, 0), leaves); + + assertTrue(place(tree(true), terrain, placement()) >= 0); + + assertSame(wood, terrain.written.get(new Position(0, 83, 0))); + } + + @Test + public void ordinaryObjectCanopiesKeepTheirPlacementSemantics() { + Terrain terrain = new Terrain(); + terrain.written.put(new Position(4, 84, 0), wood); + IrisObject ordinary = tree(true); + ordinary.setTreeGrounding(null); + + assertTrue(place(ordinary, terrain, placement()) >= 0); + + assertSame(leaves, terrain.written.get(new Position(4, 84, 0))); + } + + @Test + public void transformedCanopyRolesStillProtectExistingSolids() { + IrisObject source = tree(true); + IrisObject shrunk = source.copy(); + shrunk.shrinkwrap(); + for (IrisObject transformed : List.of(source.copy(), shrunk, + source.rotateCopy(IrisObjectRotation.of(0, 90, 0)), + source.scaled(2, IrisObjectPlacementScaleInterpolator.NONE), + source.scaledAroundOrigin(2, IrisObjectPlacementScaleInterpolator.NONE))) { + assertGroundingMatchesBlocks(transformed); + TreeBlockCanvas.Vec canopy = transformed.getTreeGrounding().canopy().iterator().next(); + Position target = new Position(canopy.x(), 100 + canopy.y(), canopy.z()); + NativeBlockState existing = state("test:older_trunk"); + Terrain terrain = new Terrain(); + terrain.written.put(target, existing); + + assertTrue(transformed.place(0, 100, 0, terrain.placer, placement(), new RNG(42L), data) >= 0); + + assertSame(existing, terrain.written.get(target)); + } + } + + private int place(IrisObject tree, Terrain terrain, IrisObjectPlacement placement) { + return tree.place(0, -1, 0, terrain.placer, placement, new RNG(42L), data); + } + + private IrisObjectPlacement placement() { + return new IrisObjectPlacement().setMode(ObjectPlaceMode.CENTER_HEIGHT) + .setRotation(IrisObjectRotation.of(0, 0, 0)).setSurfaceSupportBuffer(0); + } + + private IrisObject tree(boolean rooted) { + IrisObject tree = new IrisObject(9, 7, 3); + tree.setLoader(data); + tree.getBlocks().put(new IrisBlockVector(0, -2, 0), wood); + tree.getBlocks().put(new IrisBlockVector(0, -1, 0), wood); + tree.getBlocks().put(new IrisBlockVector(0, 0, 0), wood); + tree.getBlocks().put(new IrisBlockVector(1, -2, 0), wood); + tree.getBlocks().put(new IrisBlockVector(4, 0, 0), wood); + tree.getBlocks().put(new IrisBlockVector(4, 1, 0), leaves); + List roots = new ArrayList<>(); + if (rooted) { + tree.getBlocks().put(new IrisBlockVector(0, -3, 0), wood); + roots.add(new TreeGrounding.Root(0, -3, 0, wood)); + } else { + roots.add(new TreeGrounding.Root(0, -2, 0, wood)); + } + roots.add(new TreeGrounding.Root(1, -2, 0, wood)); + tree.setTreeGrounding(TreeGrounding.create(roots, rooted ? 3 : 0, + Set.of(new TreeBlockCanvas.Vec(4, 1, 0)))); + return tree; + } + + private static Map snapshot(IrisObject tree) { + Map snapshot = new HashMap<>(); + for (Map.Entry entry : tree.getBlocks()) { + IrisBlockVector position = entry.getKey(); + snapshot.put(new Position(position.getBlockX(), position.getBlockY(), position.getBlockZ()), entry.getValue()); + } + return snapshot; + } + + private void assertGroundingMatchesBlocks(IrisObject tree) { + TreeGrounding grounding = tree.getTreeGrounding(); + assertNotNull(grounding); + assertFalse(grounding.roots().isEmpty()); + Set columns = new HashSet<>(); + for (TreeGrounding.Root root : grounding.roots()) { + assertTrue(columns.add(new Column(root.x(), root.z()))); + assertSame(root.state(), tree.getBlocks().get(new IrisBlockVector(root.x(), root.y(), root.z()))); + assertFalse(root.state().isFoliage()); + } + for (Map.Entry entry : tree.getBlocks()) { + IrisBlockVector cell = entry.getKey(); + assertEquals(entry.getValue() == leaves, grounding.isCanopy(cell.getBlockX(), cell.getBlockY(), cell.getBlockZ())); + } + } + + private static NativeBlockState state(String key) { + NativeBlockState state = mock(NativeBlockState.class); + when(state.key()).thenReturn(key); + when(state.materialKey()).thenReturn(key); + when(state.isSolid()).thenReturn(true); + when(state.isOccluding()).thenReturn(true); + return state; + } + + private static final class Terrain { + private final IObjectPlacer placer = mock(IObjectPlacer.class); + private final Map heights = new HashMap<>(); + private final Map written = new HashMap<>(); + private final Set carved = new HashSet<>(); + + private Terrain() { + when(placer.beginTreeCanopy(any())).thenCallRealMethod(); + when(placer.getHighest(anyInt(), anyInt(), any())).thenAnswer(invocation -> + surface(invocation.getArgument(0), invocation.getArgument(1))); + when(placer.getHighest(anyInt(), anyInt(), any(), anyBoolean())).thenAnswer(invocation -> + surface(invocation.getArgument(0), invocation.getArgument(1))); + when(placer.isSurfaceSolid(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> { + int x = invocation.getArgument(0); + int y = invocation.getArgument(1); + int z = invocation.getArgument(2); + return y >= 0 && y <= surface(x, z) && !carved.contains(new Position(x, y, z)); + }); + when(placer.isCarved(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> + carved.contains(new Position(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)))); + when(placer.get(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> + written.get(new Position(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)))); + doAnswer(invocation -> { + Position position = new Position(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)); + NativeBlockState state = invocation.getArgument(3); + written.put(position, state); + return null; + }).when(placer).set(anyInt(), anyInt(), anyInt(), any()); + } + + private int surface(int x, int z) { + return heights.getOrDefault(new Column(x, z), SURFACE_Y); + } + } + + private record Column(int x, int z) { + } + + private record Position(int x, int y, int z) { + } + + private record Palette(String trunk, String canopy) { + } +}